feat: vẽ section item canvas
This commit is contained in:
+415
-54
@@ -677,7 +677,7 @@ const WaveformLane = ({
|
||||
// Draw sections
|
||||
const sections = track.sections || [];
|
||||
sections.forEach(sec => {
|
||||
const secStartLocal = sec.start * zoom - scrollLeft;
|
||||
const secStartLocal = sec.start * zoom - scrollLeft; // use secStartLocal NOT secStart
|
||||
const secWidth = sec.duration * zoom;
|
||||
if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return;
|
||||
ctx.fillStyle = sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)';
|
||||
@@ -690,6 +690,67 @@ const WaveformLane = ({
|
||||
ctx.fillStyle = '#e4e4e7';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.fillText(sec.name || 'Section', Math.max(secStartLocal + 4, 4), 14);
|
||||
|
||||
// Draw sub-tracks within section
|
||||
const subTracks = sec.tracks || [];
|
||||
const subTrackCount = Math.min(subTracks.length, 4);
|
||||
const subTrackHeight = (height - 20) / Math.max(1, subTrackCount);
|
||||
const subColors = ['#fbbf24', '#a78bfa', '#ec4899', '#10b981'];
|
||||
for (let stIdx = 0; stIdx < subTrackCount; stIdx++) {
|
||||
const sub = subTracks[stIdx];
|
||||
if (!sub) continue;
|
||||
const subY = 20 + stIdx * subTrackHeight;
|
||||
ctx.fillStyle = sub.color ? sub.color + '22' : subColors[stIdx] + '22';
|
||||
ctx.fillRect(secStartLocal + 1, subY, secWidth - 2, subTrackHeight - 1);
|
||||
// Draw clips as waveform bars
|
||||
const subClips = sub.clips || [];
|
||||
subClips.forEach(cl => {
|
||||
if (!cl.buffer) return;
|
||||
const sr = cl.buffer.sampleRate;
|
||||
const bufData = cl.buffer.getChannelData(0);
|
||||
const bufLen = bufData.length;
|
||||
const clStartLocal = cl.startTime || 0;
|
||||
const clDurLocal = bufLen / sr / (cl.speed || 1.0);
|
||||
const clStartMain = secStartLocal + clStartLocal * zoom;
|
||||
const clW = Math.max(1, clDurLocal * zoom);
|
||||
const peakSamples = Math.max(10, Math.min(100, Math.floor(clW / 3)));
|
||||
const step = Math.max(1, Math.floor(bufLen / peakSamples));
|
||||
for (let p = 0; p < peakSamples; p++) {
|
||||
const sIdx = p * step;
|
||||
let maxVal = 0;
|
||||
for (let j = 0; j < step && sIdx + j < bufLen; j++) {
|
||||
const abs = Math.abs(bufData[sIdx + j]);
|
||||
if (abs > maxVal) maxVal = abs;
|
||||
}
|
||||
const bx = clStartMain + (p / peakSamples) * clW;
|
||||
const barH = Math.max(1, maxVal * (subTrackHeight * 0.7));
|
||||
const barY = subY + (subTrackHeight - barH) / 2;
|
||||
ctx.fillStyle = subColors[stIdx] + '99';
|
||||
ctx.fillRect(bx, barY, Math.max(1, clW / peakSamples), barH);
|
||||
}
|
||||
});
|
||||
// Draw MIDI items as colored note bars
|
||||
const subMidi = sub.midiItems || [];
|
||||
subMidi.forEach(item => {
|
||||
const itemStartLocal = secStartLocal + (item.startTime || 0) * zoom;
|
||||
const itemDurLocal = (item.duration || 1) * zoom;
|
||||
const notes = item.notes || [];
|
||||
const pitchMin = 36;
|
||||
const pitchMax = 84;
|
||||
notes.forEach(note => {
|
||||
const beatSec = 60.0 / (parseInt(bpm) || 120);
|
||||
const noteStartSec = (note.start_beat || 0) * beatSec;
|
||||
const noteDurSec = Math.max(0.02, (note.duration_beats || 0.25) * beatSec);
|
||||
const noteStartLocal = itemStartLocal + noteStartSec * zoom;
|
||||
const nw = noteDurSec * zoom;
|
||||
const pitchFrac = Math.max(0, Math.min(1, (note.pitch - pitchMin) / (pitchMax - pitchMin)));
|
||||
const ny = subY + 2 + (1.0 - pitchFrac) * (subTrackHeight - 6);
|
||||
const nh = Math.max(4, (subTrackHeight - 6) / (pitchMax - pitchMin) * 3);
|
||||
ctx.fillStyle = subColors[stIdx] + 'cc';
|
||||
ctx.fillRect(Math.max(noteStartLocal, secStartLocal + 2), ny, Math.max(2, nw), nh);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Draw MIDI items
|
||||
@@ -4371,6 +4432,47 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
const [draggedNote, setDraggedNote] = React.useState(null); // { mode: 'move'|'resize', idx, startOffsetBeat, originalStart }
|
||||
const [hoveredResizeIdx, setHoveredResizeIdx] = React.useState(-1);
|
||||
|
||||
// Undo/redo stacks
|
||||
const undoStackRef = React.useRef([]);
|
||||
const redoStackRef = React.useRef([]);
|
||||
const notesBeforeDragRef = React.useRef(null);
|
||||
|
||||
const pushToUndo = React.useCallback((prevNotes) => {
|
||||
undoStackRef.current.push(JSON.parse(JSON.stringify(prevNotes)));
|
||||
redoStackRef.current = [];
|
||||
if (undoStackRef.current.length > 50) undoStackRef.current.shift();
|
||||
}, []);
|
||||
|
||||
const handleUndo = React.useCallback(() => {
|
||||
const prev = undoStackRef.current.pop();
|
||||
if (!prev) return;
|
||||
redoStackRef.current.push(JSON.parse(JSON.stringify(notes)));
|
||||
setNotes(prev);
|
||||
setSelectedNoteIds([]);
|
||||
}, [notes]);
|
||||
|
||||
const handleRedo = React.useCallback(() => {
|
||||
const next = redoStackRef.current.pop();
|
||||
if (!next) return;
|
||||
undoStackRef.current.push(JSON.parse(JSON.stringify(notes)));
|
||||
setNotes(next);
|
||||
setSelectedNoteIds([]);
|
||||
}, [notes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handler = e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
} else if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [handleUndo, handleRedo]);
|
||||
|
||||
React.useEffect(() => {
|
||||
onUpdateNotes(st.id, notes);
|
||||
}, [notes]);
|
||||
@@ -4644,6 +4746,7 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
|
||||
});
|
||||
if (clickedNote) {
|
||||
pushToUndo(notes);
|
||||
setNotes(prev => prev.filter(n => n.id !== clickedNote.id));
|
||||
setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id));
|
||||
showToast('Đã xóa nốt nhanh!', 'info');
|
||||
@@ -4659,33 +4762,35 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
});
|
||||
|
||||
// Click on note → play note with SoundFont
|
||||
if (clickedNoteIdx !== -1 && !e.ctrlKey && !e.shiftKey) {
|
||||
if (clickedNoteIdx !== -1 && !e.ctrlKey && !e.shiftKey && !e.altKey) {
|
||||
if (window.SonicSF) {
|
||||
const ctx = getAudioContext();
|
||||
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+click: selection mode (select notes by touching)
|
||||
if (e.ctrlKey) {
|
||||
// Ctrl+click: duplicate or selection
|
||||
if (e.ctrlKey && !e.altKey) {
|
||||
if (clickedNoteIdx !== -1) {
|
||||
// Ctrl+click on note: toggle selection
|
||||
pushToUndo(notes);
|
||||
const clickedNote = notes[clickedNoteIdx];
|
||||
if (selectedNoteIds.includes(clickedNote.id)) {
|
||||
setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id));
|
||||
} else {
|
||||
setSelectedNoteIds(prev => [...prev, clickedNote.id]);
|
||||
}
|
||||
// Start drag to select more notes
|
||||
const selectedNotesOffset = notes
|
||||
.filter(n => selectedNoteIds.includes(n.id) || n.id === notes[clickedNoteIdx].id)
|
||||
.map(n => ({ id: n.id, originalStartBeat: n.start_beat, originalPitch: n.pitch }));
|
||||
// Duplicate: clone all selected notes + clicked note
|
||||
const idsToClone = [...new Set([...selectedNoteIds, clickedNote.id])];
|
||||
const clones = notes.filter(n => idsToClone.includes(n.id)).map(n => ({
|
||||
...JSON.parse(JSON.stringify(n)),
|
||||
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 8)
|
||||
}));
|
||||
setNotes(prev => [...prev, ...clones]);
|
||||
const cloneIds = clones.map(c => c.id);
|
||||
setSelectedNoteIds(cloneIds);
|
||||
notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes));
|
||||
const cloneOffsets = clones.map(n => ({ id: n.id, originalStartBeat: n.start_beat, originalPitch: n.pitch }));
|
||||
setDraggedNote({
|
||||
mode: 'move',
|
||||
idx: clickedNoteIdx,
|
||||
startOffsetBeat: beat - notes[clickedNoteIdx].start_beat,
|
||||
startOffsetPitch: pitch - notes[clickedNoteIdx].pitch,
|
||||
selectedNotesOffset: selectedNotesOffset
|
||||
idx: -1,
|
||||
startOffsetBeat: beat,
|
||||
startOffsetPitch: pitch,
|
||||
selectedNotesOffset: cloneOffsets
|
||||
});
|
||||
} else {
|
||||
// Ctrl+click on empty space: start selection marquee
|
||||
@@ -4698,8 +4803,28 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
return;
|
||||
}
|
||||
|
||||
// Hovered resize edge
|
||||
// Hovered resize edge (Alt+resize for scaling)
|
||||
if (hoveredResizeIdx !== -1 && e.altKey) {
|
||||
pushToUndo(notes);
|
||||
notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes));
|
||||
const allSelected = [...new Set(selectedNoteIds.length > 0 ? selectedNoteIds : [notes[hoveredResizeIdx].id])];
|
||||
const selectedNotes = notes.filter(n => allSelected.includes(n.id));
|
||||
const firstStart = Math.min(...selectedNotes.map(n => n.start_beat));
|
||||
const draggedNote = notes[hoveredResizeIdx];
|
||||
setDraggedNote({
|
||||
mode: 'scale',
|
||||
idx: hoveredResizeIdx,
|
||||
originalEnd: draggedNote.start_beat + draggedNote.duration_beats,
|
||||
firstStart: firstStart,
|
||||
selectedNoteIds: allSelected
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Hovered resize edge (normal resize)
|
||||
if (hoveredResizeIdx !== -1) {
|
||||
pushToUndo(notes);
|
||||
notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes));
|
||||
setDraggedNote({
|
||||
mode: 'resize',
|
||||
idx: hoveredResizeIdx,
|
||||
@@ -4711,15 +4836,22 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
if (clickedNoteIdx !== -1) {
|
||||
// Click on existing note: drag-move
|
||||
const clickedNote = notes[clickedNoteIdx];
|
||||
let nextSelectedIds;
|
||||
if (!selectedNoteIds.includes(clickedNote.id)) {
|
||||
if (e.shiftKey) {
|
||||
setSelectedNoteIds(prev => [...prev, clickedNote.id]);
|
||||
nextSelectedIds = [...selectedNoteIds, clickedNote.id];
|
||||
setSelectedNoteIds(nextSelectedIds);
|
||||
} else {
|
||||
setSelectedNoteIds([clickedNote.id]);
|
||||
nextSelectedIds = [clickedNote.id];
|
||||
setSelectedNoteIds(nextSelectedIds);
|
||||
}
|
||||
} else {
|
||||
nextSelectedIds = selectedNoteIds;
|
||||
}
|
||||
pushToUndo(notes);
|
||||
notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes));
|
||||
const selectedNotesOffset = notes
|
||||
.filter(n => selectedNoteIds.includes(n.id) || n.id === clickedNote.id)
|
||||
.filter(n => nextSelectedIds.includes(n.id))
|
||||
.map(n => ({
|
||||
id: n.id,
|
||||
originalStartBeat: n.start_beat,
|
||||
@@ -4733,7 +4865,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
selectedNotesOffset: selectedNotesOffset
|
||||
});
|
||||
} else {
|
||||
// Click on empty space: DRAW a new note starting from click position
|
||||
// Click on empty space: DRAW a new note (brush mode with visitedPitches)
|
||||
pushToUndo(notes);
|
||||
const start = getSnapBeat(beat, snapVal);
|
||||
const initialDur = getSnapDuration(snapVal);
|
||||
const noteId = 'note_' + Date.now() + Math.random().toString(36).substr(2, 5);
|
||||
@@ -4753,7 +4886,10 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
startOffsetBeat: start,
|
||||
startOffsetPitch: pitch,
|
||||
drawNoteId: noteId,
|
||||
drawDuration: initialDur
|
||||
drawDuration: initialDur,
|
||||
visitedPitches: [pitch],
|
||||
initialBeat: start,
|
||||
initialPitch: pitch
|
||||
});
|
||||
// Play the note with SoundFont
|
||||
if (window.SonicSF) {
|
||||
@@ -4823,6 +4959,47 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
||||
if (n.id !== draggedNote.drawNoteId) return n;
|
||||
return { ...n, duration_beats: newDur };
|
||||
}));
|
||||
// Brush: track visited pitches and create evenly-spaced notes
|
||||
const visited = draggedNote.visitedPitches || [];
|
||||
if (!visited.includes(pitch)) {
|
||||
const newPitches = [...visited, pitch];
|
||||
const totalSpan = Math.max(0.125, beat - draggedNote.initialBeat);
|
||||
const perNoteDur = totalSpan / newPitches.length;
|
||||
const brushIds = draggedNote.brushIds || [];
|
||||
setNotes(prev => {
|
||||
const cleaned = prev.filter(n => !brushIds.includes(n.id));
|
||||
const brushNotes = newPitches.map((p, i) => ({
|
||||
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 8) + '_' + i,
|
||||
pitch: p,
|
||||
start_beat: draggedNote.initialBeat + i * perNoteDur,
|
||||
duration_beats: perNoteDur * 0.9,
|
||||
velocity: 0.8,
|
||||
pan: 0.0
|
||||
}));
|
||||
const newBrushIds = brushNotes.map(bn => bn.id);
|
||||
setSelectedNoteIds(newBrushIds);
|
||||
draggedNote.brushIds = newBrushIds;
|
||||
draggedNote.visitedPitches = newPitches;
|
||||
return [...cleaned, ...brushNotes];
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (draggedNote.mode === 'scale') {
|
||||
const newEnd = getSnapBeat(Math.max(draggedNote.firstStart + 0.125, beat), snapVal);
|
||||
const scaleFactor = (newEnd - draggedNote.firstStart) / (draggedNote.originalEnd - draggedNote.firstStart);
|
||||
const ids = draggedNote.selectedNoteIds || [];
|
||||
const firstStart = draggedNote.firstStart;
|
||||
setNotes(prev => prev.map(n => {
|
||||
if (!ids.includes(n.id)) return n;
|
||||
const relStart = n.start_beat - firstStart;
|
||||
const relEnd = relStart + n.duration_beats;
|
||||
return {
|
||||
...n,
|
||||
start_beat: firstStart + relStart * scaleFactor,
|
||||
duration_beats: Math.max(0.125, relEnd * scaleFactor - relStart * scaleFactor)
|
||||
};
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (draggedNote.mode === 'resize') {
|
||||
@@ -5407,6 +5584,12 @@ const App = () => {
|
||||
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 [instrumentSelectorTrackId, setInstrumentSelectorTrackId] = useState(null);
|
||||
const [fxSelectorTrackId, setFxSelectorTrackId] = useState(null);
|
||||
const handleSetTrackFx = (trackId, fxType) => {
|
||||
updateActiveTracks(prev => prev.map(t => t.id === trackId ? { ...t, fxType } : t));
|
||||
setFxSelectorTrackId(null);
|
||||
setTimeout(() => lucide.createIcons(), 50);
|
||||
};
|
||||
const [instrumentSelectorData, setInstrumentSelectorData] = useState(null);
|
||||
const openInstrumentSelector = trackId => {
|
||||
setInstrumentSelectorTrackId(trackId);
|
||||
@@ -5435,6 +5618,32 @@ const App = () => {
|
||||
const [synthCategory, setSynthCategory] = useState(null); // 'vst' | 'soundfont'
|
||||
const [selectedSoundFontId, setSelectedSoundFontId] = useState(null);
|
||||
const [sfPresets, setSfPresets] = useState(null); // presets from SoundFont
|
||||
const [instrumentDropdownTrackId, setInstrumentDropdownTrackId] = useState(null);
|
||||
const [instrumentDropdownBtnRect, setInstrumentDropdownBtnRect] = useState(null);
|
||||
const [instrumentSearchQuery, setInstrumentSearchQuery] = useState('');
|
||||
const filteredInstruments = useMemo(() => {
|
||||
if (!instrumentSelectorData || !instrumentSearchQuery) return { soundfonts: instrumentSelectorData?.soundfonts || [], vst: instrumentSelectorData?.vst_instruments || [] };
|
||||
const q = instrumentSearchQuery.toLowerCase();
|
||||
return {
|
||||
soundfonts: (instrumentSelectorData.soundfonts || []).filter(sf => (sf.display || sf.name || sf.id).toLowerCase().includes(q)),
|
||||
vst: (instrumentSelectorData.vst_instruments || []).filter(v => (v.name || v.id).toLowerCase().includes(q))
|
||||
};
|
||||
}, [instrumentSearchQuery, instrumentSelectorData]);
|
||||
useEffect(() => {
|
||||
if (!instrumentSelectorData) {
|
||||
window.SonicAPI.listPlugins().then(data => setInstrumentSelectorData(data)).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!instrumentDropdownTrackId) return;
|
||||
const handler = e => {
|
||||
setInstrumentDropdownTrackId(null);
|
||||
setInstrumentDropdownBtnRect(null);
|
||||
setInstrumentSearchQuery('');
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, [instrumentDropdownTrackId]);
|
||||
const GM_INSTRUMENTS = [
|
||||
"Acoustic Grand Piano","Bright Acoustic Piano","Electric Grand Piano","Honky-tonk Piano","Electric Piano 1","Electric Piano 2","Harpsichord","Clavi",
|
||||
"Celesta","Glockenspiel","Music Box","Vibraphone","Marimba","Xylophone","Tubular Bells","Dulcimer",
|
||||
@@ -5458,12 +5667,16 @@ const App = () => {
|
||||
if (t.id !== trackId) return t;
|
||||
return { ...t, instrumentId, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName };
|
||||
}));
|
||||
setInstrumentDropdownTrackId(null);
|
||||
setInstrumentDropdownBtnRect(null);
|
||||
setInstrumentSelectorTrackId(null);
|
||||
setSynthCategory(null);
|
||||
setSelectedSoundFontId(null);
|
||||
setTimeout(() => lucide.createIcons(), 50);
|
||||
};
|
||||
const setTrackInstrument = (trackId, instrumentId, displayName) => {
|
||||
setInstrumentDropdownTrackId(null);
|
||||
setInstrumentDropdownBtnRect(null);
|
||||
if (instrumentId && instrumentId.startsWith('sf_')) {
|
||||
// Set instrument on track immediately so Synth button shows the name
|
||||
updateActiveTracks(prev => prev.map(t => {
|
||||
@@ -5605,6 +5818,7 @@ const App = () => {
|
||||
const [showFxRack, setShowFxRack] = useState(false);
|
||||
const [showMidiEvents, setShowMidiEvents] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
|
||||
const [tcpWidth, setTcpWidth] = useState(320);
|
||||
const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50);
|
||||
const [panelPositions, setPanelPositions] = useState({
|
||||
export: 'bottom',
|
||||
@@ -6072,6 +6286,24 @@ const App = () => {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
// ── TCP Resizer ──
|
||||
const startTcpResize = e => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = tcpWidth;
|
||||
const onMove = ev => {
|
||||
const deltaX = ev.clientX - startX;
|
||||
const newWidth = Math.max(280, Math.min(600, startWidth + deltaX));
|
||||
setTcpWidth(newWidth);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
// ── Right Sidebar Row Resizer (Media Explorer / AI Panel) ──
|
||||
const startRowResize = e => {
|
||||
e.preventDefault();
|
||||
@@ -6851,17 +7083,19 @@ const App = () => {
|
||||
const secondsPerBar = secondsPerBeat * 4;
|
||||
const durationSec = (tab.length_bars || 16.0) * secondsPerBar;
|
||||
|
||||
const contentTracks = tab.tracks ? tab.tracks.filter(tr => tr.clips?.length > 0 || tr.midiItems?.length > 0) : [];
|
||||
|
||||
setTracks(prev => prev.map(t => {
|
||||
if (!t.sections || t.sections.length === 0) return t;
|
||||
return {
|
||||
...t,
|
||||
sections: t.sections.map(s => {
|
||||
if (s.sectionId !== tab.sectionId) return s;
|
||||
if (s.sectionId !== tab.sectionId && s.id !== tab.sectionId) return s;
|
||||
return {
|
||||
...s,
|
||||
name: tab.name,
|
||||
duration: durationSec,
|
||||
tracks: tab.tracks
|
||||
tracks: contentTracks
|
||||
};
|
||||
})
|
||||
};
|
||||
@@ -6883,7 +7117,7 @@ const App = () => {
|
||||
if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; }
|
||||
const tabId = 'session_' + Date.now();
|
||||
const tabName = section.name || 'Section';
|
||||
const clonedTracks = section.tracks ? section.tracks : tracks.map(t => ({
|
||||
const clonedTracks = section.tracks ? JSON.parse(JSON.stringify(section.tracks)) : tracks.filter(t => t.id === trackId).map(t => ({
|
||||
...t,
|
||||
clips: [],
|
||||
sections: [],
|
||||
@@ -8472,6 +8706,53 @@ const App = () => {
|
||||
return () => cancelAnimationFrame(animationFrameIdRef.current);
|
||||
}, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]);
|
||||
|
||||
// ── FX Nodes ──
|
||||
const createChorusNode = (context, inputNode, outputNode) => {
|
||||
const dryGain = context.createGain();
|
||||
dryGain.gain.value = 0.6;
|
||||
const wetGain = context.createGain();
|
||||
wetGain.gain.value = 0.5;
|
||||
const delayNode = context.createDelay();
|
||||
delayNode.delayTime.value = 0.02;
|
||||
const lfo = context.createOscillator();
|
||||
lfo.type = 'sine';
|
||||
lfo.frequency.value = 1.5;
|
||||
const lfoGain = context.createGain();
|
||||
lfoGain.gain.value = 0.002;
|
||||
lfo.connect(lfoGain);
|
||||
lfoGain.connect(delayNode.delayTime);
|
||||
lfo.start();
|
||||
inputNode.connect(dryGain);
|
||||
inputNode.connect(delayNode);
|
||||
delayNode.connect(wetGain);
|
||||
dryGain.connect(outputNode);
|
||||
wetGain.connect(outputNode);
|
||||
return { stop: () => { try { lfo.stop(); } catch(e) {} } };
|
||||
};
|
||||
const createReverbNode = (context, inputNode, outputNode) => {
|
||||
const dryGain = context.createGain();
|
||||
dryGain.gain.value = 0.6;
|
||||
const wetGain = context.createGain();
|
||||
wetGain.gain.value = 0.4;
|
||||
const convolver = context.createConvolver();
|
||||
const rate = context.sampleRate;
|
||||
const len = rate * 2.0;
|
||||
const impulse = context.createBuffer(2, len, rate);
|
||||
const left = impulse.getChannelData(0);
|
||||
const right = impulse.getChannelData(1);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const decay = Math.exp(-i / (rate * 0.5));
|
||||
left[i] = (Math.random() * 2 - 1) * decay;
|
||||
right[i] = (Math.random() * 2 - 1) * decay;
|
||||
}
|
||||
convolver.buffer = impulse;
|
||||
inputNode.connect(dryGain);
|
||||
inputNode.connect(convolver);
|
||||
convolver.connect(wetGain);
|
||||
dryGain.connect(outputNode);
|
||||
wetGain.connect(outputNode);
|
||||
};
|
||||
|
||||
// ── Playback ──
|
||||
const getOrCreateTrackNode = (track, context) => {
|
||||
if (!track) return null;
|
||||
@@ -8484,12 +8765,41 @@ const App = () => {
|
||||
const pannerNode = context.createStereoPanner();
|
||||
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
|
||||
pannerNode.connect(context.destination);
|
||||
gainNode.connect(pannerNode);
|
||||
node = { gainNode, pannerNode };
|
||||
let fxStopFn;
|
||||
if (track.fxType === 'chorus') {
|
||||
const fxInput = context.createGain();
|
||||
gainNode.connect(fxInput);
|
||||
const chorus = createChorusNode(context, fxInput, pannerNode);
|
||||
fxStopFn = chorus.stop;
|
||||
} else if (track.fxType === 'reverb') {
|
||||
const fxInput = context.createGain();
|
||||
gainNode.connect(fxInput);
|
||||
createReverbNode(context, fxInput, pannerNode);
|
||||
fxStopFn = null;
|
||||
} else {
|
||||
gainNode.connect(pannerNode);
|
||||
}
|
||||
node = { gainNode, pannerNode, fxStopFn };
|
||||
activeTrackNodesRef.current[track.id] = node;
|
||||
}
|
||||
return node.gainNode;
|
||||
};
|
||||
const getOrCreateSubTrackNode = (track, subTrack, context) => {
|
||||
if (!track || !subTrack) return null;
|
||||
const subKey = track.id + '_sub_' + subTrack.id;
|
||||
let node = activeTrackNodesRef.current[subKey];
|
||||
if (!node) {
|
||||
const gainNode = context.createGain();
|
||||
const volDb = subTrack.volumeDb ?? 0;
|
||||
const volLinear = volDb <= -50 ? 0 : Math.pow(10, volDb / 20);
|
||||
gainNode.gain.setValueAtTime(volLinear, context.currentTime);
|
||||
const parentNode = getOrCreateTrackNode(track, context);
|
||||
gainNode.connect(parentNode);
|
||||
node = { gainNode, pannerNode: null };
|
||||
activeTrackNodesRef.current[subKey] = node;
|
||||
}
|
||||
return node.gainNode;
|
||||
};
|
||||
|
||||
const playMidiPreviewNote = (pitch, velocity = 0.8, durationMs = 500) => {
|
||||
if (!window.SonicSF) return;
|
||||
@@ -8611,6 +8921,9 @@ const App = () => {
|
||||
const isSubPlayable = hasSubSolo ? subTrack.solo : !subTrack.muted;
|
||||
if (!isSubPlayable) return;
|
||||
|
||||
const subNode = getOrCreateSubTrackNode(track, subTrack, context);
|
||||
if (!subNode) return;
|
||||
|
||||
// 1. Play clips in subTrack
|
||||
const subClips = subTrack.clips || [];
|
||||
subClips.forEach(clip => {
|
||||
@@ -8630,7 +8943,7 @@ const App = () => {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = clip.buffer;
|
||||
source.playbackRate.value = clip.speed || 1.0;
|
||||
source.connect(gainNode);
|
||||
source.connect(subNode);
|
||||
|
||||
if (offsetTime < activeStart) {
|
||||
const delay = activeStart - offsetTime;
|
||||
@@ -8674,7 +8987,7 @@ const App = () => {
|
||||
playDurMs,
|
||||
startTime,
|
||||
program,
|
||||
gainNode
|
||||
subNode
|
||||
);
|
||||
} else {
|
||||
const remainingDurMs = (notePlayEndMain - offsetTime) * 1000;
|
||||
@@ -8684,7 +8997,7 @@ const App = () => {
|
||||
remainingDurMs,
|
||||
context.currentTime,
|
||||
program,
|
||||
gainNode
|
||||
subNode
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8864,6 +9177,9 @@ const App = () => {
|
||||
} catch (e) { }
|
||||
});
|
||||
activeSourcesRef.current = [];
|
||||
Object.values(activeTrackNodesRef.current).forEach(n => {
|
||||
if (n.fxStopFn) n.fxStopFn();
|
||||
});
|
||||
activeTrackNodesRef.current = {};
|
||||
if (window.SonicSF) {
|
||||
window.SonicSF.stopAll();
|
||||
@@ -13799,8 +14115,9 @@ const App = () => {
|
||||
}, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||
ref: tcpContainerRef,
|
||||
onScroll: handleTCPScroll,
|
||||
className: "w-[320px] shrink-0 z-20 bg-[#262626] overflow-y-auto flex flex-col border-r border-zinc-900 no-scrollbar",
|
||||
className: "shrink-0 z-20 bg-[#262626] overflow-y-auto flex flex-col border-r border-zinc-900 no-scrollbar",
|
||||
style: {
|
||||
width: tcpWidth + 'px',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}
|
||||
@@ -13991,10 +14308,10 @@ const App = () => {
|
||||
title: "ARM (Record)",
|
||||
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${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'}`
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "circle", className: `w-2.5 h-2.5 ${track.isArmed ? 'fill-white' : ''}` })), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: e => { e.stopPropagation(); openInstrumentSelector(track.id); },
|
||||
onClick: e => { e.stopPropagation(); const btn = e.currentTarget; setInstrumentDropdownTrackId(prev => prev === track.id ? null : track.id); setInstrumentDropdownBtnRect(btn.getBoundingClientRect()); setInstrumentSearchQuery(''); },
|
||||
title: track.instrumentName || track.instrumentId || "Synth",
|
||||
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 ${track.instrumentId ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "music", className: "w-3 h-3" })), /*#__PURE__*/React.createElement("button", {
|
||||
className: `px-1.5 py-0.5 text-[10px] rounded font-mono font-bold border transition flex items-center gap-0.5 max-w-[60px] ${track.instrumentId ? 'bg-violet-900 text-violet-300 border-violet-700' : 'bg-zinc-800 text-zinc-500 border-transparent hover:text-zinc-300'}`
|
||||
}, /*#__PURE__*/React.createElement("i", { "data-lucide": "music", className: "w-3 h-3 shrink-0" }), /*#__PURE__*/React.createElement("span", { className: "truncate text-[9px]" }, track.instrumentName || track.instrumentId || (instrumentDropdownTrackId === track.id ? '' : 'Synth')), /*#__PURE__*/React.createElement("i", { "data-lucide": "chevron-down", className: "w-2.5 h-2.5 shrink-0" })), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
toggleTrackMonitor(track.id);
|
||||
@@ -14109,10 +14426,7 @@ const App = () => {
|
||||
"data-lucide": "upload",
|
||||
className: "w-3 h-3"
|
||||
})), " File"), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: e => {
|
||||
e.stopPropagation();
|
||||
showToast('FX panel for track ' + track.id, 'info');
|
||||
},
|
||||
onClick: e => { e.stopPropagation(); setFxSelectorTrackId(track.id); },
|
||||
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-purple-400 border border-purple-900 rounded text-xs font-bold flex items-center gap-1"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
@@ -14121,17 +14435,15 @@ const App = () => {
|
||||
className: "w-3 h-3"
|
||||
})), " FX: ", /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-zinc-500 font-normal"
|
||||
}, " None")), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: (e) => { e.stopPropagation(); openInstrumentSelector(track.id); },
|
||||
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-xs font-bold flex items-center gap-1"
|
||||
}, track.fxType || "None")), /*#__PURE__*/React.createElement("button", {
|
||||
onClick: (e) => { e.stopPropagation(); const btn = e.currentTarget; setInstrumentDropdownTrackId(prev => prev === track.id ? null : track.id); setInstrumentDropdownBtnRect(btn.getBoundingClientRect()); setInstrumentSearchQuery(''); },
|
||||
className: "px-2.5 py-1 bg-zinc-800 hover:bg-zinc-700 text-amber-400 border border-amber-800 rounded text-xs font-bold flex items-center gap-1 max-w-[120px]"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "inline-flex items-center shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("i", {
|
||||
"data-lucide": "music",
|
||||
className: "w-3 h-3"
|
||||
})), " Synth: ", /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-zinc-500 font-normal"
|
||||
}, track.instrumentName || track.instrumentId || "None"))), /*#__PURE__*/React.createElement("div", {
|
||||
})), /*#__PURE__*/React.createElement("span", { className: "truncate text-[10px]" }, track.instrumentName || track.instrumentId || "Synth"), /*#__PURE__*/React.createElement("i", { "data-lucide": "chevron-down", className: "w-3 h-3 shrink-0" }))), /*#__PURE__*/React.createElement("div", {
|
||||
onMouseDown: e => handleTrackResizeMouseDown(e, track.id),
|
||||
className: "absolute bottom-0 left-0 right-0 h-1 cursor-ns-resize z-30 hover:bg-cyan-500/50 transition-colors",
|
||||
onClick: e => e.stopPropagation()
|
||||
@@ -14139,6 +14451,9 @@ const App = () => {
|
||||
})), /*#__PURE__*/React.createElement("div", {
|
||||
className: "h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0"
|
||||
})), /*#__PURE__*/React.createElement("div", {
|
||||
className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",
|
||||
onMouseDown: startTcpResize
|
||||
}), /*#__PURE__*/React.createElement("div", {
|
||||
ref: handleTimelineWrapperRef,
|
||||
onScroll: handleTimelineScroll,
|
||||
className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"
|
||||
@@ -14354,13 +14669,14 @@ const App = () => {
|
||||
})() : (st.buffer && 'duration' in st.buffer ? st.buffer.duration : 4.0);
|
||||
const subTabTimelineWidth = Math.max(zoom * subTabDuration, viewportWidth);
|
||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||
className: "w-[320px] shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900",
|
||||
style: {
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0"
|
||||
className: "shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900",
|
||||
style: {
|
||||
width: tcpWidth + 'px',
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none'
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "sticky top-0 z-50 flex items-center h-10 border-b border-zinc-900 bg-[#242424] px-2 justify-between shrink-0"
|
||||
}, /*#__PURE__*/React.createElement("span", {
|
||||
className: "text-xs font-bold text-zinc-500 uppercase"
|
||||
}, "Sub-Tab"), /*#__PURE__*/React.createElement("button", {
|
||||
@@ -14664,6 +14980,9 @@ const App = () => {
|
||||
})), " Save"))) : /*#__PURE__*/React.createElement("div", {
|
||||
className: "flex-1 flex items-center justify-center text-xs text-zinc-500"
|
||||
}, "Track not found")), /*#__PURE__*/React.createElement("div", {
|
||||
className: "w-1 cursor-ew-resize hover:bg-cyan-500/50 active:bg-cyan-400 transition-colors shrink-0 z-10",
|
||||
onMouseDown: startTcpResize
|
||||
}), /*#__PURE__*/React.createElement("div", {
|
||||
ref: handleTimelineWrapperRef,
|
||||
className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
@@ -15272,7 +15591,49 @@ const App = () => {
|
||||
/*#__PURE__*/React.createElement("p", { className: "text-xs text-zinc-500 py-4 text-center" }, "No plugins available. Upload SoundFont via Tools \u2192 Plugin Manager.")
|
||||
)
|
||||
)
|
||||
))));
|
||||
))), fxSelectorTrackId && /*#__PURE__*/React.createElement("div", {
|
||||
className: "fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm",
|
||||
onClick: () => setFxSelectorTrackId(null)
|
||||
}, /*#__PURE__*/React.createElement("div", {
|
||||
className: "bg-[#262626] border border-[#383838] rounded-xl shadow-2xl w-64 p-4 text-slate-200",
|
||||
onClick: e => e.stopPropagation()
|
||||
}, /*#__PURE__*/React.createElement("h3", { className: "text-sm font-bold text-purple-400 mb-3" }, "Track FX"), /*#__PURE__*/React.createElement("div", { className: "space-y-1" },
|
||||
/*#__PURE__*/React.createElement("button", { onClick: () => handleSetTrackFx(fxSelectorTrackId, null), className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-400" }, "None"),
|
||||
/*#__PURE__*/React.createElement("button", { onClick: () => handleSetTrackFx(fxSelectorTrackId, 'chorus'), className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-cyan-800 text-cyan-300" }, "Chorus"),
|
||||
/*#__PURE__*/React.createElement("button", { onClick: () => handleSetTrackFx(fxSelectorTrackId, 'reverb'), className: "w-full text-left px-3 py-2 text-xs rounded bg-zinc-800 hover:bg-purple-800 text-purple-300" }, "Reverb")
|
||||
))), instrumentDropdownTrackId && instrumentDropdownBtnRect && /*#__PURE__*/React.createElement("div", {
|
||||
"data-instr-dropdown": "",
|
||||
className: "fixed z-[100] bg-[#1e1e1e] border border-zinc-700 rounded shadow-xl w-[220px] max-h-[300px] flex flex-col",
|
||||
style: {
|
||||
top: instrumentDropdownBtnRect.bottom + 4,
|
||||
left: Math.max(4, Math.min(instrumentDropdownBtnRect.left, window.innerWidth - 224))
|
||||
}
|
||||
}, /*#__PURE__*/React.createElement("input", {
|
||||
type: "text",
|
||||
placeholder: "T\u00ecm nh\u1ea1c c\u1ee5...",
|
||||
value: instrumentSearchQuery,
|
||||
onChange: e => setInstrumentSearchQuery(e.target.value),
|
||||
autoFocus: true,
|
||||
className: "w-full bg-black border-b border-zinc-700 px-2 py-1.5 text-xs outline-none"
|
||||
}), /*#__PURE__*/React.createElement("div", { className: "flex-1 overflow-y-auto" },
|
||||
/*#__PURE__*/React.createElement("button", {
|
||||
onClick: () => setTrackInstrumentWithProgram(instrumentDropdownTrackId, null),
|
||||
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-zinc-700 text-zinc-400"
|
||||
}, "None (Default Synth)"),
|
||||
filteredInstruments.soundfonts.length > 0 && /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold" }, "SoundFonts"),
|
||||
filteredInstruments.soundfonts.map((sf, i) => /*#__PURE__*/React.createElement("button", {
|
||||
key: "sfd_" + i,
|
||||
onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrument(instrumentDropdownTrackId, "sf_" + sf.id, sf.display || sf.name || sf.id); },
|
||||
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-amber-800 text-zinc-200 flex items-center justify-between"
|
||||
}, /*#__PURE__*/React.createElement("span", { className: "truncate" }, sf.display || sf.name || sf.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-amber-400 shrink-0 ml-1" }, "SF"))),
|
||||
filteredInstruments.vst.length > 0 && /*#__PURE__*/React.createElement("div", { className: "text-[10px] text-zinc-500 px-3 py-1 mt-1 uppercase font-bold" }, "VST Instruments"),
|
||||
filteredInstruments.vst.map((v, i) => /*#__PURE__*/React.createElement("button", {
|
||||
key: "vstd_" + i,
|
||||
onClick: () => { setInstrumentDropdownTrackId(null); setInstrumentDropdownBtnRect(null); setTrackInstrumentWithProgram(instrumentDropdownTrackId, v.id, undefined, v.name || v.id); },
|
||||
className: "w-full text-left px-3 py-1.5 text-xs bg-zinc-800 hover:bg-violet-800 text-zinc-200 flex items-center justify-between"
|
||||
}, /*#__PURE__*/React.createElement("span", { className: "truncate" }, v.name || v.id), /*#__PURE__*/React.createElement("span", { className: "text-[10px] text-cyan-400 shrink-0 ml-1" }, v.type || "VST"))),
|
||||
(!filteredInstruments.soundfonts.length && !filteredInstruments.vst.length) && /*#__PURE__*/React.createElement("p", { className: "text-xs text-zinc-500 py-4 text-center" }, "Kh\u00f4ng t\u00ecm th\u1ea5y nh\u1ea1c c\u1ee5 n\u00e0o")
|
||||
)));
|
||||
};
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(/*#__PURE__*/React.createElement(App, null));
|
||||
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"original_name": "test.sf2", "uuid": "3c4f0cfc-ecc4-4ae8-adeb-14c0d04d9e20", "file": "3c4f0cfc-ecc4-4ae8-adeb-14c0d04d9e20.sf2"}
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
{"original_name": "test.sf2", "uuid": "f5aa67a6-d998-4013-b61a-ff0b6adfe311", "file": "f5aa67a6-d998-4013-b61a-ff0b6adfe311.sf2"}
|
||||
Binary file not shown.
Reference in New Issue
Block a user