fix: lỗi ghi note midi vào track

This commit is contained in:
2026-07-23 16:07:40 +07:00
parent 92877d02b7
commit b460d40824
4 changed files with 483 additions and 426 deletions
+135 -137
View File
@@ -64,7 +64,7 @@ const formatBeat = (secs, bpmVal) => {
return `${bar}.${beat}.${sub}`;
};
const midiPitchToName = pitch => {
const names = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const names = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const octave = Math.floor(pitch / 12) - 1;
return names[pitch % 12] + octave;
};
@@ -116,6 +116,7 @@ class ClientMIDIRecorder {
this.recordedNotes = [];
this.recStartAudioTime = 0.0;
this.recStartBar = 0.0;
this.selectedMidiInputId = null;
// Compute round-trip browser latency
this.latencyCompSec = (this.audioCtx.baseLatency || 0) + (this.audioCtx.outputLatency || 0);
@@ -127,23 +128,15 @@ class ClientMIDIRecorder {
this.activeNotes.clear();
this.recStartBar = startBar;
this.recStartAudioTime = this.audioCtx.currentTime;
this.bindMIDIInputs(selectedMidiInputId);
this.selectedMidiInputId = 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) {
handleMIDIMessage(event, sourceInputId = null) {
if (!this.isRecording) return;
if (this.selectedMidiInputId && this.selectedMidiInputId !== 'ALL' && sourceInputId && sourceInputId !== this.selectedMidiInputId) {
console.log(`[DevLog] [MIDI Rec] Ignoring input message from "${sourceInputId}" (Selected: "${this.selectedMidiInputId}")`);
return;
}
const [status, pitch, velocity] = event.data;
const command = status >> 4;
@@ -151,17 +144,19 @@ class ClientMIDIRecorder {
// 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);
const currentBeat = currentTimeSec / secondsPerBeat;
// Command 0x9: Note On
if (command === 0x9 && velocity > 0) {
const noteId = `rec_${Date.now()}_${pitch}`;
this.activeNotes.set(pitch, {
const newActiveNote = {
id: noteId,
pitch: pitch,
start_beat: currentBeat,
velocity: velocity / 127.0
});
};
this.activeNotes.set(pitch, newActiveNote);
console.log(`[DevLog] [MIDI Rec] Note On - Pitch: ${pitch}, Velocity: ${velocity}, StartBeat: ${currentBeat.toFixed(3)}, latencyCompSec: ${this.latencyCompSec.toFixed(3)}, noteObj:`, newActiveNote);
// Fire visual feedback callback
if (this.onNoteOn) {
@@ -185,6 +180,7 @@ class ClientMIDIRecorder {
this.recordedNotes.push(finishedNote);
this.activeNotes.delete(pitch);
console.log(`[DevLog] [MIDI Rec] Note Off - Pitch: ${pitch}, DurationBeats: ${durationBeats.toFixed(3)}, FinishedNote:`, finishedNote);
if (this.onNoteOff) {
this.onNoteOff(pitch, finishedNote);
@@ -198,29 +194,24 @@ class ClientMIDIRecorder {
// 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);
const currentBeat = currentTimeSec / (60.0 / this.bpm);
for (let [pitch, note] of this.activeNotes.entries()) {
this.recordedNotes.push({
const durationBeats = Math.max(0.25, currentBeat - note.start_beat);
const finishedNote = {
id: note.id,
pitch: note.pitch,
start_beat: note.start_beat,
duration_beats: Math.max(0.25, currentBeat - note.start_beat),
duration_beats: durationBeats,
velocity: note.velocity,
pan: 0.0
});
};
this.recordedNotes.push(finishedNote);
console.log(`[DevLog] [MIDI Rec] Flushing active keypress on stop - Pitch: ${pitch}, DurationBeats: ${durationBeats.toFixed(3)}, FinishedNote:`, finishedNote);
}
this.activeNotes.clear();
// Disconnect midi Access callbacks
if (navigator.requestMIDIAccess) {
navigator.requestMIDIAccess().then(midiAccess => {
for (let input of midiAccess.inputs.values()) {
input.onmidimessage = null;
}
});
}
console.log(`[DevLog] [MIDI Rec] Recording stopped. Total notes: ${this.recordedNotes.length}. List:`, this.recordedNotes);
return this.recordedNotes;
}
}
@@ -295,7 +286,7 @@ class ClientAudioRecorder {
if (this.sourceNode && this.workletNode) {
try {
this.sourceNode.disconnect(this.workletNode);
} catch (e) {}
} catch (e) { }
}
if (this.mediaStream) {
@@ -453,7 +444,7 @@ const WaveformLane = ({
if (snapValue && snapValue !== 'free') {
const beatDuration = 60 / parseFloat(bpm || 120);
let divisor = 1;
if (snapValue === '1') divisor = 1;else if (snapValue === '1/2') divisor = 0.5;else if (snapValue === '1/4') divisor = 0.25;else if (snapValue === '1/8') divisor = 0.125;else if (snapValue === '1/16') divisor = 0.0625;else if (snapValue === '1/32') divisor = 0.03125;
if (snapValue === '1') divisor = 1; else if (snapValue === '1/2') divisor = 0.5; else if (snapValue === '1/4') divisor = 0.25; else if (snapValue === '1/8') divisor = 0.125; else if (snapValue === '1/16') divisor = 0.0625; else if (snapValue === '1/32') divisor = 0.03125;
gridSpacing = beatDuration * divisor;
} else {
gridSpacing = 60 / parseFloat(bpm || 120);
@@ -707,14 +698,21 @@ const WaveformLane = ({
const midiStartLocal = midi.startTime * zoom - scrollLeft;
const midiWidth = midi.duration * zoom;
if (midiStartLocal + midiWidth < 0 || midiStartLocal > drawWidth) return;
ctx.fillStyle = '#a78bfa33';
const isRecordingItem = midi.name === 'Recording...';
if (isRecordingItem && Math.random() < 0.05) {
console.log(`[DevLog] [Canvas Draw] Rendering MIDI item: ID=${midi.id}, Name="${midi.name}", Start=${midiStartLocal.toFixed(1)}px, Width=${midiWidth.toFixed(1)}px, NotesCount=${midi.notes?.length || 0}`);
}
ctx.fillStyle = isRecordingItem ? 'rgba(239, 68, 68, 0.2)' : '#a78bfa33';
ctx.fillRect(midiStartLocal, 2, midiWidth, height - 4);
ctx.strokeStyle = '#a78bfa';
ctx.strokeStyle = isRecordingItem ? '#ef4444' : '#a78bfa';
ctx.lineWidth = 1.5;
ctx.strokeRect(midiStartLocal, 2, midiWidth, height - 4);
ctx.fillStyle = '#c4b5fd';
ctx.fillStyle = isRecordingItem ? '#fca5a5' : '#c4b5fd';
ctx.font = 'bold 9px sans-serif';
ctx.fillText(midi.name || 'MIDI', Math.max(midiStartLocal + 4, 4), 14);
ctx.fillText(isRecordingItem ? '[Ghi MIDI...]' : (midi.name || 'MIDI'), Math.max(midiStartLocal + 4, 4), 14);
const midiNotes = midi.notes || [];
if (midiNotes.length > 0) {
@@ -732,47 +730,12 @@ const WaveformLane = ({
const ny = 18 + (1.0 - pitchFrac) * (height - 26);
const nh = Math.max(6, (height - 26) / (pitchMax - pitchMin) * 4);
ctx.fillStyle = '#a78bfa';
ctx.fillStyle = isRecordingItem ? '#10b981' : '#a78bfa';
ctx.fillRect(Math.max(noteStartLocal, midiStartLocal + 2), ny, Math.max(2, nw), nh);
});
}
});
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 = Math.max(6, (height - 18) / (pitchMax - pitchMin) * 4);
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;
@@ -783,7 +746,7 @@ const WaveformLane = ({
ctx.lineWidth = 1;
ctx.strokeRect(hlLeftLocal, 0, hlWidth, height);
}
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft, recordingState, recTempMidiNotes, recStartTimelineTime, canvasRedrawCount]);
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft, recordingState, recTempMidiNotes, recStartTimelineTime, canvasRedrawCount, currentTime]);
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
key: "virtual-spacer",
style: {
@@ -1238,7 +1201,7 @@ const TempoTrackLane = ({
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 0.8;
let divisor = 1;
if (snapValue === '1') divisor = 1;else if (snapValue === '1/2') divisor = 0.5;else if (snapValue === '1/4') divisor = 0.25;else if (snapValue === '1/8') divisor = 0.125;else if (snapValue === '1/16') divisor = 0.0625;else if (snapValue === '1/32') divisor = 0.03125;
if (snapValue === '1') divisor = 1; else if (snapValue === '1/2') divisor = 0.5; else if (snapValue === '1/4') divisor = 0.25; else if (snapValue === '1/8') divisor = 0.125; else if (snapValue === '1/16') divisor = 0.0625; else if (snapValue === '1/32') divisor = 0.03125;
const snapInterval = beatDuration * divisor;
if (snapInterval * zoom >= 4) {
const firstSnap = Math.floor(tStart / snapInterval) * snapInterval;
@@ -1748,7 +1711,7 @@ const SubTabWaveform = ({
for (let px = Math.floor(x0); px < Math.ceil(x1); px++) {
const t = px / zoom;
const y = hermiteY(t, t0, y0, m0, t1, y1, m1);
if (px === Math.floor(x0) && i === 0) ctx.moveTo(px, y);else ctx.lineTo(px, y);
if (px === Math.floor(x0) && i === 0) ctx.moveTo(px, y); else ctx.lineTo(px, y);
}
}
ctx.stroke();
@@ -2674,7 +2637,7 @@ const GraphEditorCanvas = ({
nodes.forEach((n, i) => {
const x = n.time / buffer.duration * drawWidth;
const y = nodeY(n, h);
if (i === 0) ctx.moveTo(x, y);else ctx.lineTo(x, y);
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
nodes.forEach((n, i) => {
@@ -3102,11 +3065,11 @@ const AIConfigModal = ({
},
className: "px-2 py-1 bg-red-800 hover:bg-red-700 text-white text-xs font-bold rounded"
}, "Xóa"), providers.length > 1 && /*#__PURE__*/React.createElement("button", {
onClick: function(e) { e.stopPropagation(); var provs = providers.slice(); var pos = -1; for (var pi = 0; pi < provs.length; pi++) { if (provs[pi].id === selectedId) { pos = pi; break; } } if (pos > 0) { var tmp = provs[pos]; provs[pos] = provs[pos-1]; provs[pos-1] = tmp; setProviders(provs); } },
onClick: function (e) { e.stopPropagation(); var provs = providers.slice(); var pos = -1; for (var pi = 0; pi < provs.length; pi++) { if (provs[pi].id === selectedId) { pos = pi; break; } } if (pos > 0) { var tmp = provs[pos]; provs[pos] = provs[pos - 1]; provs[pos - 1] = tmp; setProviders(provs); } },
className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded",
title: "Di chuyển lên"
}, "▲"), providers.length > 1 && /*#__PURE__*/React.createElement("button", {
onClick: function(e) { e.stopPropagation(); var provs = providers.slice(); var pos = -1; for (var pi = 0; pi < provs.length; pi++) { if (provs[pi].id === selectedId) { pos = pi; break; } } if (pos < provs.length - 1) { var tmp = provs[pos]; provs[pos] = provs[pos+1]; provs[pos+1] = tmp; setProviders(provs); } },
onClick: function (e) { e.stopPropagation(); var provs = providers.slice(); var pos = -1; for (var pi = 0; pi < provs.length; pi++) { if (provs[pi].id === selectedId) { pos = pi; break; } } if (pos < provs.length - 1) { var tmp = provs[pos]; provs[pos] = provs[pos + 1]; provs[pos + 1] = tmp; setProviders(provs); } },
className: "px-2 py-1 bg-zinc-700 hover:bg-zinc-600 text-white text-xs font-bold rounded",
title: "Di chuyển xuống"
}, "▼"))), activeProvider && /*#__PURE__*/React.createElement("form", {
@@ -4325,10 +4288,10 @@ const serializeProjectToSchema = (projectId, name, bpmVal, tracksList, subTabsLi
name: m.name || "MIDI Item",
type: "MIDI_ITEM",
start_bar: m.startTime / secondsPerBar,
duration_bars: m.duration || 4.0,
duration_bars: m.duration ? (m.duration / secondsPerBar) : 4.0,
clip_start_offset_bars: 0.0,
source_data: {
total_buffer_bars: m.duration || 8.0,
total_buffer_bars: m.duration ? (m.duration / secondsPerBar) : 8.0,
notes: (m.notes || []).map(n => ({
id: n.id || 'note_' + Math.random().toString(36).substr(2, 9),
pitch: n.pitch || 60,
@@ -4442,7 +4405,7 @@ const deserializeProjectFromSchema = (schemaObj) => {
id: item.id,
name: item.name,
startTime: item.start_bar * secondsPerBar,
duration: item.duration_bars,
duration: item.duration_bars * secondsPerBar,
notes: src.notes || []
});
} else if (item.type === "SECTION_ITEM") {
@@ -4551,7 +4514,7 @@ const App = () => {
const [draggedClip, setDraggedClip] = useState(null); // { trackId, clickOffset, buffer, name, volume, color }
const [hoveredTrackId, setHoveredTrackId] = useState(null);
const openPanel = id => {
if (id === 'export') setShowExportPanel(true);else if (id === 'ai') setShowAIPanel(true);else if (id === 'python_tools') setShowPythonToolsPanel(true);else if (id === 'selection') setShowSelectionPanel(true);
if (id === 'export') setShowExportPanel(true); else if (id === 'ai') setShowAIPanel(true); else if (id === 'python_tools') setShowPythonToolsPanel(true); else if (id === 'selection') setShowSelectionPanel(true);
};
const [activeTool, setActiveTool] = useState('select'); // 'select' | 'grab' | 'razor'
const [snapValue, setSnapValue] = useState('free'); // 'free', '1', '1/2', '1/4', '1/8', '1/16', '1/32'
@@ -4560,7 +4523,7 @@ const App = () => {
if (snapVal === 'free') return time;
const beatDuration = 60 / parseFloat(bpmVal || 120);
let divisor = 1;
if (snapVal === '1') divisor = 1;else if (snapVal === '1/2') divisor = 0.5;else if (snapVal === '1/4') divisor = 0.25;else if (snapVal === '1/8') divisor = 0.125;else if (snapVal === '1/16') divisor = 0.0625;else if (snapVal === '1/32') divisor = 0.03125;
if (snapVal === '1') divisor = 1; else if (snapVal === '1/2') divisor = 0.5; else if (snapVal === '1/4') divisor = 0.25; else if (snapVal === '1/8') divisor = 0.125; else if (snapVal === '1/16') divisor = 0.0625; else if (snapVal === '1/32') divisor = 0.03125;
const gridSpacing = beatDuration * divisor;
return Math.round(time / gridSpacing) * gridSpacing;
};
@@ -4591,7 +4554,8 @@ const App = () => {
const [currentProjectId, setCurrentProjectId] = useState(() => localStorage.getItem('sonic_project_id') || null);
const [saveProjectModalOpen, setSaveProjectModalOpen] = useState(false);
const [saveAsModalOpen, setSaveAsModalOpen] = useState(false);
const [soloedTrackId, setSoloedTrackId] = useState(null);
const soloedTrack = tracks.find(t => t.solo);
const soloedTrackId = soloedTrack ? soloedTrack.id : null;
const [toastMessage, setToastMessage] = useState(null);
const [audioDevices, setAudioDevices] = useState([]);
const [midiDevices, setMidiDevices] = useState([]);
@@ -4608,6 +4572,7 @@ const App = () => {
for (let input of access.inputs.values()) {
inputs.push(input);
input.onmidimessage = msg => {
console.log(`[DevLog] [Raw MIDI] Received from "${input.name}" (ID: ${input.id}) - Data:`, Array.from(msg.data));
if (msg.data.length < 3) return;
const cmd = msg.data[0] >> 4;
const pitch = msg.data[1];
@@ -4623,6 +4588,16 @@ const App = () => {
setLastMidiNote(prev => prev && prev.pitch === pitch ? { ...prev, length: lenSec, time: Date.now() } : prev);
}
}
// Forward to active MIDI recorders
if (activeMIDIRecordersRef.current) {
for (let trackId in activeMIDIRecordersRef.current) {
const rec = activeMIDIRecordersRef.current[trackId];
if (rec) {
rec.handleMIDIMessage(msg, input.id);
}
}
}
};
}
setMidiDevices(inputs);
@@ -4777,7 +4752,7 @@ const App = () => {
showToast(`Redo: ${last.action_type}`, 'info');
};
const applyTrackState = (trackId, state) => {
setTracks(prev => prev.map(t => {
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
return {
...t,
@@ -4786,7 +4761,7 @@ const App = () => {
}));
};
const captureTrackSnapshot = trackId => {
const track = tracks.find(t => t.id === trackId);
const track = activeTracks.find(t => t.id === trackId);
if (!track) return null;
return {
volumeDb: track.volumeDb,
@@ -4873,7 +4848,7 @@ const App = () => {
} catch (err) {
const cached = localStorage.getItem('sonic_user');
if (cached) {
try { setCurrentUser(JSON.parse(cached)); } catch (_) {}
try { setCurrentUser(JSON.parse(cached)); } catch (_) { }
setIsMandatoryLogin(false);
setAuthModalOpen(false);
} else {
@@ -4963,7 +4938,7 @@ const App = () => {
const active = data.providers.find(p => p.is_active) || data.providers[0];
if (active) setSelectedProviderId(active.id);
}
} catch (e) {}
} catch (e) { }
})();
}
};
@@ -5050,7 +5025,7 @@ const App = () => {
const h = rect.height;
const margin = 60;
let zone = null;
if (x < margin && x > 10) zone = 'left';else if (x > w - margin && x < w - 10) zone = 'right';else if (y < margin && y > 10) zone = 'top';else if (y > h - margin && y < h - 10) zone = 'bottom';
if (x < margin && x > 10) zone = 'left'; else if (x > w - margin && x < w - 10) zone = 'right'; else if (y < margin && y > 10) zone = 'top'; else if (y > h - margin && y < h - 10) zone = 'bottom';
panelDropZoneRef.current = zone;
setPanelDropZone(zone);
setDragGhostPos({
@@ -5869,7 +5844,9 @@ const App = () => {
clips: [],
sections: [],
midiItems: [],
markers: []
markers: [],
isArmed: false,
monitoringEnabled: true
}));
setSessionTabs(prev => [...prev, { id: tabId, name: tabName, sectionId: sectionId, tracks: clonedTracks }]);
setActiveTab(tabId);
@@ -6654,7 +6631,7 @@ const App = () => {
speed
} = clipboardRef.current;
const ctx = getAudioContext();
const targetTrack = tracks.find(t => t.id === targetTrackId);
const targetTrack = activeTracks.find(t => t.id === targetTrackId);
const newClip = {
id: nextClipId(),
startTime: pasteTime,
@@ -6666,7 +6643,7 @@ const App = () => {
...(speed !== undefined ? { speed } : {})
};
if (targetTrack) {
setTracks(p => p.map(t => {
updateActiveTracks(p => p.map(t => {
if (t.id === targetTrackId) {
const existingClips = t.clips && t.clips.length > 0 ? t.clips : t.buffer ? [{
id: 'default_' + t.id,
@@ -6696,7 +6673,7 @@ const App = () => {
// No matching track create a new one
const colors = ['#0f766e', '#1d4ed8', '#701a75', '#a21caf', '#b45309'];
const newId = 'track_pasted_' + Date.now();
setTracks(prev => [...prev, {
updateActiveTracks(prev => [...prev, {
id: newId,
name: `Pasted_${name || 'track'}`,
buffer: clipBuffer,
@@ -6815,7 +6792,7 @@ const App = () => {
showToast('Merged all unmuted tracks.', 'success');
};
const handleCopyTrack = () => {
const t = tracks.find(x => x.id === selectedTrackId);
const t = activeTracks.find(x => x.id === selectedTrackId);
if (!t || !t.buffer) return;
const sr = t.buffer.sampleRate;
const data = t.buffer.getChannelData(0);
@@ -6843,19 +6820,19 @@ const App = () => {
}
// No selection: copy entire track
clipboardRef.current = {
buffer: clipBuffer,
buffer: t.buffer,
name: t.name,
volumeDb: t.volumeDb,
pan: t.pan,
color: t.color,
sampleRate: clipBuffer.sampleRate,
channels: clipBuffer.numberOfChannels,
sampleRate: t.buffer.sampleRate,
channels: t.buffer.numberOfChannels,
speed: t.speed || 1.0
};
showToast('Copied track to clipboard.', 'info');
};
const handleCutTrack = () => {
const t = tracks.find(x => x.id === selectedTrackId);
const t = activeTracks.find(x => x.id === selectedTrackId);
if (!t || !t.buffer) return;
const beforeSnap = captureTrackSnapshot(selectedTrackId);
const sr = t.buffer.sampleRate;
@@ -6886,7 +6863,7 @@ const App = () => {
let idx = 0;
for (let i = 0; i < startSample; i++) newData[idx++] = data[i];
for (let i = endSample; i < data.length; i++) newData[idx++] = data[i];
setTracks(p => p.map(tr => tr.id === selectedTrackId ? {
updateActiveTracks(p => p.map(tr => tr.id === selectedTrackId ? {
...tr,
buffer: newBuffer
} : tr));
@@ -6933,7 +6910,7 @@ const App = () => {
}, [timelineWrapperNode]);
useEffect(() => {
fetch(API_BASE_URL).then(r => {
if (r.ok) setServerStatus('connected');else setServerStatus('error');
if (r.ok) setServerStatus('connected'); else setServerStatus('error');
}).catch(() => setServerStatus('offline'));
}, []);
@@ -7175,7 +7152,7 @@ const App = () => {
volNodes.forEach((n, i) => {
const t = context.currentTime + n.time / speed;
const linearGain = Math.pow(10, n.db / 20);
if (i === 0) volumeGainNode.gain.setValueAtTime(linearGain, t);else volumeGainNode.gain.linearRampToValueAtTime(linearGain, t);
if (i === 0) volumeGainNode.gain.setValueAtTime(linearGain, t); else volumeGainNode.gain.linearRampToValueAtTime(linearGain, t);
});
}
@@ -7186,7 +7163,7 @@ const App = () => {
panNodes.forEach((n, i) => {
const t = context.currentTime + n.time / speed;
const clamped = Math.max(-1, Math.min(1, n.pan));
if (i === 0) pannerNode.pan.setValueAtTime(clamped, t);else pannerNode.pan.linearRampToValueAtTime(clamped, t);
if (i === 0) pannerNode.pan.setValueAtTime(clamped, t); else pannerNode.pan.linearRampToValueAtTime(clamped, t);
});
}
@@ -7274,21 +7251,28 @@ const App = () => {
}
// MIDI preview
let combinedNotes = [];
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 => ({
const currentBeat = (audioCtx.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / secondsPerBeat;
const notes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
}))]);
}))];
combinedNotes = combinedNotes.concat(notes);
}
}
if (combinedNotes.length > 0 || armedTracks.some(t => activeMIDIRecordersRef.current[t.id])) {
setRecTempMidiNotes(combinedNotes);
setCanvasRedrawCount(n => n + 1);
}
}
}
if (activeTabRef.current !== 'main') {
const isSubTab = subTabsRef.current.some(sub => sub.id === activeTabRef.current);
if (isSubTab) {
const st = subTabsRef.current.find(s => s.id === activeTabRef.current);
if (!st || !st.isPlaying || !st.buffer) return;
const context = getAudioContext();
@@ -7537,7 +7521,7 @@ const App = () => {
activeSourcesRef.current.forEach(src => {
try {
src.stop();
} catch (e) {}
} catch (e) { }
});
activeSourcesRef.current = [];
activeTrackNodesRef.current = {};
@@ -7665,7 +7649,7 @@ const App = () => {
id: tempMidiItemId,
name: 'Recording...',
startTime: startTimelineTime,
duration: 4,
duration: 4 * (secondsPerBeat * 4),
notes: []
}]
};
@@ -7677,8 +7661,9 @@ const App = () => {
drawVuMeter(canvas, 0);
setTimeout(() => drawVuMeter(canvas, -60), 100);
}
const elapsedBeats = Math.max(0, currentBeat - midiRec.recStartBar * 4);
const durBars = Math.max(1, Math.ceil(elapsedBeats / 4) + 1);
const elapsedBeats = Math.max(0, currentBeat);
const elapsedSec = elapsedBeats * secondsPerBeat;
const durationSec = Math.max(4 * (secondsPerBeat * 4), elapsedSec);
const allNotes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
@@ -7690,16 +7675,18 @@ const App = () => {
if (t.id !== track.id) return t;
return {
...t,
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m)
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durationSec } : m)
};
}));
}
};
midiRec.onNoteOff = () => {
const currentBeat = (context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / (60.0 / midiRec.bpm) + (midiRec.recStartBar * 4);
const elapsedBeats = Math.max(0, currentBeat - midiRec.recStartBar * 4);
const durBars = Math.max(1, Math.ceil(elapsedBeats / 4) + 1);
const currentBeat = (context.currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec) / (60.0 / midiRec.bpm);
const elapsedBeats = Math.max(0, currentBeat);
const elapsedSec = elapsedBeats * secondsPerBeat;
const durationSec = Math.max(4 * (secondsPerBeat * 4), elapsedSec);
const activeNotesArray = Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
@@ -7712,7 +7699,7 @@ const App = () => {
if (t.id !== track.id) return t;
return {
...t,
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m)
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durationSec } : m)
};
}));
}
@@ -7761,19 +7748,22 @@ const App = () => {
for (let { trackId, midiRec } of midiRecList) {
if (!midiRec.isRecording || !midiRec.tempMidiItemId) continue;
const currentTimeSec = Math.max(0, getAudioContext().currentTime - midiRec.recStartAudioTime - midiRec.latencyCompSec);
const currentBeat = currentTimeSec / secondsPerBeatInt + midiRec.recStartBar * 4;
const currentBeat = currentTimeSec / secondsPerBeatInt;
const allNotes = [...midiRec.recordedNotes, ...Array.from(midiRec.activeNotes.values()).map(n => ({
...n,
duration_beats: currentBeat - n.start_beat
}))];
const elapsedBeats = Math.max(0, currentBeat - midiRec.recStartBar * 4);
const durBars = Math.max(1, Math.ceil(elapsedBeats / 4) + 1);
const durationSec = Math.max(4 * (secondsPerBeatInt * 4), currentTimeSec);
if (Math.random() < 0.2) { // Throttle log to prevent flooding (approx 2 logs/sec)
console.log(`[DevLog] [MIDI Rec Sync] Temp Item ID: ${midiRec.tempMidiItemId}, Duration: ${durationSec.toFixed(2)}s, ActiveNotes: ${midiRec.activeNotes.size}, RecordedNotes: ${midiRec.recordedNotes.length}`);
}
setRecTempMidiNotes(allNotes);
setCanvasRedrawCount(n => n + 1);
updateActiveTracks(prev => prev.map(t => {
if (t.id !== trackId) return t;
return {
...t,
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durBars } : m)
midiItems: t.midiItems.map(m => m.id === midiRec.tempMidiItemId ? { ...m, notes: allNotes, duration: durationSec } : m)
};
}));
}
@@ -7788,6 +7778,7 @@ const App = () => {
const context = getAudioContext();
const secondsPerBeat = 60.0 / (parseInt(bpm) || 120);
const secondsPerBar = secondsPerBeat * 4;
const midiRecorders = activeMIDIRecordersRef.current;
const audioRecorders = activeAudioRecordersRef.current;
@@ -7817,7 +7808,7 @@ const App = () => {
...updatedItems[itemIndex],
name: recordedNotes.length > 0 ? 'Recorded MIDI' : 'Empty MIDI',
notes: recordedNotes,
duration: Math.ceil(totalDurationBeats / 4)
duration: Math.ceil(totalDurationBeats / 4) * secondsPerBar
};
return { ...t, midiItems: updatedItems };
}
@@ -7825,7 +7816,7 @@ const App = () => {
id: 'midi_rec_' + Date.now(),
name: 'Recorded MIDI',
startTime: recordingStartTimeRef.current,
duration: Math.ceil(totalDurationBeats / 4),
duration: Math.ceil(totalDurationBeats / 4) * secondsPerBar,
notes: recordedNotes
};
return {
@@ -7844,7 +7835,7 @@ const App = () => {
id: 'midi_rec_' + Date.now(),
name: 'Recorded MIDI',
startTime: recordingStartTimeRef.current,
duration: Math.ceil(totalDurationBeats / 4),
duration: Math.ceil(totalDurationBeats / 4) * secondsPerBar,
notes: recordedNotes
};
updateActiveTracks(prev => prev.map(t => {
@@ -8387,6 +8378,7 @@ const App = () => {
const time = mouseX / zoom;
const newStart = Math.max(0, time - drag.clickOffset);
const targetTrackId = hoveredTrackIdRef.current || drag.trackId;
const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
updateActiveTracks(prev => prev.map(t => {
const items = drag.itemType === 'section' ? (t.sections || []) : (t.midiItems || []);
const updatedItems = items.filter(it => it.id !== drag.itemId);
@@ -8398,9 +8390,9 @@ const App = () => {
: { ...movedItem, startTime: newStart });
} else {
if (drag.itemType === 'section') {
updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4, color: '#06b6d4' });
updatedItems.push({ id: drag.itemId, name: 'Section', start: newStart, duration: 4 * secondsPerBar, color: '#06b6d4' });
} else {
updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4, notes: [], color: '#a78bfa' });
updatedItems.push({ id: drag.itemId, name: 'MIDI Item', startTime: newStart, duration: 4 * secondsPerBar, notes: [], color: '#a78bfa' });
}
}
}
@@ -8605,7 +8597,6 @@ const App = () => {
solo: false
};
}));
setSoloedTrackId(prev => prev === trackId ? null : trackId);
if (wasPlaying) {
setTimeout(() => {
startOffsetTimeRef.current = currentTime;
@@ -8905,11 +8896,12 @@ const App = () => {
const curTracks = activeTracks;
const track = curTracks.find(t => t.id === selectedTrackId);
if (!track) { showToast('Chọn track trước', 'warning'); return; }
const secondsPerBar = (60.0 / (parseInt(bpm) || 120)) * 4;
const midiItem = {
id: `midi_${Date.now()}`,
name: 'MIDI Item',
startTime: currentTime,
duration: 4,
duration: 4 * secondsPerBar,
notes: [],
color: '#a78bfa'
};
@@ -9918,7 +9910,7 @@ const App = () => {
const active = data.providers.find(p => p.is_active) || data.providers[0];
if (active) setSelectedProviderId(active.id);
}
} catch (e) {}
} catch (e) { }
}
const prv = aiProviders.find(p => p.id === selectedProviderId) || (aiProviders.length > 0 ? aiProviders[0] : null);
const provider = prv || aiConfig;
@@ -10493,7 +10485,7 @@ const App = () => {
const smp = Math.max(-1, Math.min(1, renderedBuffer.getChannelData(ch)[i]));
if (bd === 8) vw.setUint8(ofs, Math.floor((smp + 1) * 127.5));
else if (bd === 16) vw.setInt16(ofs, Math.floor(smp < 0 ? smp * 0x8000 : smp * 0x7FFF), true);
else { const v24 = Math.floor(smp < 0 ? smp * 0x800000 : smp * 0x7FFFFF); vw.setUint8(ofs, v24 & 0xFF); vw.setUint8(ofs+1, v24 >> 8 & 0xFF); vw.setUint8(ofs+2, v24 >> 16 & 0xFF); }
else { const v24 = Math.floor(smp < 0 ? smp * 0x800000 : smp * 0x7FFFFF); vw.setUint8(ofs, v24 & 0xFF); vw.setUint8(ofs + 1, v24 >> 8 & 0xFF); vw.setUint8(ofs + 2, v24 >> 16 & 0xFF); }
ofs += bps;
}
}
@@ -10775,7 +10767,7 @@ const App = () => {
localStorage.setItem('sonic_preferences', JSON.stringify(prefs));
if (!currentUser || currentUser === 'cached') return;
const timer = setTimeout(async () => {
try { await window.SonicAPI.savePreferences(prefs); } catch (e) {}
try { await window.SonicAPI.savePreferences(prefs); } catch (e) { }
}, 2000);
return () => clearTimeout(timer);
}, [showAIPanel, showExportPanel, showSelectionPanel, showPythonToolsPanel,
@@ -11331,7 +11323,8 @@ const App = () => {
className: "w-[1px] h-5 bg-zinc-800 mx-0.5"
}), /*#__PURE__*/React.createElement("button", {
onClick: () => {
if (activeTab !== 'main') {
const isSubTab = subTabs.some(sub => sub.id === activeTab);
if (isSubTab) {
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
...s,
currentTime: 0
@@ -11349,7 +11342,8 @@ const App = () => {
className: "w-3.5 h-3.5"
}))), /*#__PURE__*/React.createElement("button", {
onClick: () => {
if (activeTab !== 'main') {
const isSubTab = subTabs.some(sub => sub.id === activeTab);
if (isSubTab) {
setSubTabs(prev => prev.map(s => {
if (s.id !== activeTab) return s;
const left = s.selectionStart !== null && s.selectionEnd !== null ? Math.min(s.selectionStart, s.selectionEnd) : null;
@@ -11398,7 +11392,8 @@ const App = () => {
className: "w-3.5 h-3.5 fill-current"
}))), /*#__PURE__*/React.createElement("button", {
onClick: () => {
if (activeTab !== 'main') {
const isSubTab = subTabs.some(sub => sub.id === activeTab);
if (isSubTab) {
setSubTabs(prev => prev.map(s => {
if (s.id !== activeTab) return s;
const right = s.selectionStart !== null && s.selectionEnd !== null ? Math.max(s.selectionStart, s.selectionEnd) : null;
@@ -11420,7 +11415,8 @@ const App = () => {
className: "w-3.5 h-3.5"
}))), /*#__PURE__*/React.createElement("button", {
onClick: () => {
if (activeTab !== 'main') {
const isSubTab = subTabs.some(sub => sub.id === activeTab);
if (isSubTab) {
setSubTabs(prev => prev.map(s => {
if (s.id !== activeTab) return s;
const duration = s.buffer ? s.buffer.duration / (s.speed || 1.0) : 0;
@@ -12293,7 +12289,7 @@ const App = () => {
},
title: "Solo",
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${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'}`
}, /*#__PURE__*/React.createElement("i", { "data-lucide": soloedTrackId === track.id || track.solo ? "headphones" : "headphones-off", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", {
}, /*#__PURE__*/React.createElement("i", { "data-lucide": soloedTrackId === track.id || track.solo ? "headphones" : "headphone-off", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", {
onClick: e => {
e.stopPropagation();
toggleTrackArm(track.id);
@@ -12378,7 +12374,9 @@ const App = () => {
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", {
}, /*#__PURE__*/React.createElement("option", {
value: "MIDI_KEYBOARD:ALL"
}, "Any MIDI Keyboard"), 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 && lastMidiNote && (lastMidiNote.length === 0 || Date.now() - lastMidiNote.time < 3000) && /*#__PURE__*/React.createElement("span", {
@@ -12390,7 +12388,7 @@ const App = () => {
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];
if (el) trackVuRefs.current[track.id] = el; else delete trackVuRefs.current[track.id];
},
width: 100,
height: 4,
File diff suppressed because one or more lines are too long
Binary file not shown.
+79 -21
View File
@@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -14,7 +15,7 @@
<script src="/static/js/services/storage.js"></script>
<script src="/static/js/services/aiGateway.js"></script>
<script src="/static/js/services/dawCommandDispatcher.js"></script>
<script src="/static/js/app.precompiled.js?v=20260723" defer></script>
<script src="/static/js/app.precompiled.js?v=202607231122" defer></script>
<style>
:root {
--right-sidebar-width: 320px;
@@ -31,26 +32,71 @@
overflow: hidden;
user-select: none;
}
.daw-bg { background-color: #1e1e1e; }
.daw-panel { background-color: #262626; }
.daw-header { background-color: #2e2e2e; }
.daw-border { border-color: #181818; }
.daw-track-active { background-color: #333333; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: #141414; }
::-webkit-scrollbar-thumb { background: #3a3a3a; border: 2px solid #141414; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #4a4a4a; }
.knob-container { position: relative; width: 28px; height: 28px; }
.knob-dial { transform-origin: center; transition: transform 0.1s ease; }
.daw-bg {
background-color: #1e1e1e;
}
.daw-panel {
background-color: #262626;
}
.daw-header {
background-color: #2e2e2e;
}
.daw-border {
border-color: #181818;
}
.daw-track-active {
background-color: #333333;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #141414;
}
::-webkit-scrollbar-thumb {
background: #3a3a3a;
border: 2px solid #141414;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4a4a4a;
}
.knob-container {
position: relative;
width: 28px;
height: 28px;
}
.knob-dial {
transform-origin: center;
transition: transform 0.1s ease;
}
.selection-interactive-box {
min-width: 4px;
}
.no-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
/* IE 10+ */
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Safari and Chrome */
display: none;
/* Safari and Chrome */
}
/* Fullscreen Fixed App Shell */
@@ -101,12 +147,14 @@
}
#panel-media-explorer {
height: 50%; /* Default 50/50 split */
height: 50%;
/* Default 50/50 split */
min-height: 100px;
}
#panel-ai {
flex: 1; /* Fills remaining height */
flex: 1;
/* Fills remaining height */
min-height: 100px;
}
@@ -118,7 +166,8 @@
align-items: center;
gap: 10px;
padding: 8px;
overflow-x: auto; /* Enables horizontal scroll when panels overflow */
overflow-x: auto;
/* Enables horizontal scroll when panels overflow */
overflow-y: hidden;
background-color: #161616;
border-top: 1px solid var(--panel-border-color);
@@ -129,17 +178,20 @@
.daw-bottom-strip::-webkit-scrollbar {
height: 8px;
}
.daw-bottom-strip::-webkit-scrollbar-thumb {
background: #3a3a3a;
border-radius: 4px;
}
.daw-bottom-strip::-webkit-scrollbar-thumb:hover {
background: #00ffcc;
}
/* Sub-panels inside the bottom strip */
.bottom-panel {
flex: 0 0 auto; /* Prevents shrinking, locks content dimensions */
flex: 0 0 auto;
/* Prevents shrinking, locks content dimensions */
width: 320px;
height: 100%;
background-color: #222;
@@ -151,11 +203,13 @@
/* RESIZER HANDLES */
.resizer-col-handle {
width: 5px;
cursor: ew-resize; /* Horizontal resize cursor */
cursor: ew-resize;
/* Horizontal resize cursor */
background: transparent;
transition: background 0.2s;
z-index: 10;
}
.resizer-col-handle:hover,
.resizer-col-handle:active {
background: #00ffcc;
@@ -163,18 +217,22 @@
.resizer-row-handle {
height: 5px;
cursor: ns-resize; /* Vertical resize cursor */
cursor: ns-resize;
/* Vertical resize cursor */
background: transparent;
transition: background 0.2s;
z-index: 10;
}
.resizer-row-handle:hover,
.resizer-row-handle:active {
background: #00ffcc;
}
</style>
</head>
<body class="h-screen w-screen flex flex-col">
<div id="root" class="h-full w-full flex flex-col"></div>
</body>
</html>