feat: vẽ section item canvas
This commit is contained in:
+415
-54
@@ -677,7 +677,7 @@ const WaveformLane = ({
|
|||||||
// Draw sections
|
// Draw sections
|
||||||
const sections = track.sections || [];
|
const sections = track.sections || [];
|
||||||
sections.forEach(sec => {
|
sections.forEach(sec => {
|
||||||
const secStartLocal = sec.start * zoom - scrollLeft;
|
const secStartLocal = sec.start * zoom - scrollLeft; // use secStartLocal NOT secStart
|
||||||
const secWidth = sec.duration * zoom;
|
const secWidth = sec.duration * zoom;
|
||||||
if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return;
|
if (secStartLocal + secWidth < 0 || secStartLocal > drawWidth) return;
|
||||||
ctx.fillStyle = sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)';
|
ctx.fillStyle = sec.color ? sec.color + '44' : 'rgba(6, 182, 212, 0.25)';
|
||||||
@@ -690,6 +690,67 @@ const WaveformLane = ({
|
|||||||
ctx.fillStyle = '#e4e4e7';
|
ctx.fillStyle = '#e4e4e7';
|
||||||
ctx.font = 'bold 9px sans-serif';
|
ctx.font = 'bold 9px sans-serif';
|
||||||
ctx.fillText(sec.name || 'Section', Math.max(secStartLocal + 4, 4), 14);
|
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
|
// 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 [draggedNote, setDraggedNote] = React.useState(null); // { mode: 'move'|'resize', idx, startOffsetBeat, originalStart }
|
||||||
const [hoveredResizeIdx, setHoveredResizeIdx] = React.useState(-1);
|
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(() => {
|
React.useEffect(() => {
|
||||||
onUpdateNotes(st.id, notes);
|
onUpdateNotes(st.id, notes);
|
||||||
}, [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;
|
return pitch === n.pitch && beat >= n.start_beat && beat < n.start_beat + n.duration_beats;
|
||||||
});
|
});
|
||||||
if (clickedNote) {
|
if (clickedNote) {
|
||||||
|
pushToUndo(notes);
|
||||||
setNotes(prev => prev.filter(n => n.id !== clickedNote.id));
|
setNotes(prev => prev.filter(n => n.id !== clickedNote.id));
|
||||||
setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id));
|
setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id));
|
||||||
showToast('Đã xóa nốt nhanh!', 'info');
|
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
|
// 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) {
|
if (window.SonicSF) {
|
||||||
const ctx = getAudioContext();
|
const ctx = getAudioContext();
|
||||||
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null);
|
window.SonicSF.playNote(notes[clickedNoteIdx].pitch, 100, 300, ctx.currentTime, st.instrumentProgram, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+click: selection mode (select notes by touching)
|
// Ctrl+click: duplicate or selection
|
||||||
if (e.ctrlKey) {
|
if (e.ctrlKey && !e.altKey) {
|
||||||
if (clickedNoteIdx !== -1) {
|
if (clickedNoteIdx !== -1) {
|
||||||
// Ctrl+click on note: toggle selection
|
pushToUndo(notes);
|
||||||
const clickedNote = notes[clickedNoteIdx];
|
const clickedNote = notes[clickedNoteIdx];
|
||||||
if (selectedNoteIds.includes(clickedNote.id)) {
|
// Duplicate: clone all selected notes + clicked note
|
||||||
setSelectedNoteIds(prev => prev.filter(id => id !== clickedNote.id));
|
const idsToClone = [...new Set([...selectedNoteIds, clickedNote.id])];
|
||||||
} else {
|
const clones = notes.filter(n => idsToClone.includes(n.id)).map(n => ({
|
||||||
setSelectedNoteIds(prev => [...prev, clickedNote.id]);
|
...JSON.parse(JSON.stringify(n)),
|
||||||
}
|
id: 'note_' + Date.now() + Math.random().toString(36).substr(2, 8)
|
||||||
// Start drag to select more notes
|
}));
|
||||||
const selectedNotesOffset = notes
|
setNotes(prev => [...prev, ...clones]);
|
||||||
.filter(n => selectedNoteIds.includes(n.id) || n.id === notes[clickedNoteIdx].id)
|
const cloneIds = clones.map(c => c.id);
|
||||||
.map(n => ({ id: n.id, originalStartBeat: n.start_beat, originalPitch: n.pitch }));
|
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({
|
setDraggedNote({
|
||||||
mode: 'move',
|
mode: 'move',
|
||||||
idx: clickedNoteIdx,
|
idx: -1,
|
||||||
startOffsetBeat: beat - notes[clickedNoteIdx].start_beat,
|
startOffsetBeat: beat,
|
||||||
startOffsetPitch: pitch - notes[clickedNoteIdx].pitch,
|
startOffsetPitch: pitch,
|
||||||
selectedNotesOffset: selectedNotesOffset
|
selectedNotesOffset: cloneOffsets
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Ctrl+click on empty space: start selection marquee
|
// Ctrl+click on empty space: start selection marquee
|
||||||
@@ -4698,8 +4803,28 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
|||||||
return;
|
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) {
|
if (hoveredResizeIdx !== -1) {
|
||||||
|
pushToUndo(notes);
|
||||||
|
notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes));
|
||||||
setDraggedNote({
|
setDraggedNote({
|
||||||
mode: 'resize',
|
mode: 'resize',
|
||||||
idx: hoveredResizeIdx,
|
idx: hoveredResizeIdx,
|
||||||
@@ -4711,15 +4836,22 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
|||||||
if (clickedNoteIdx !== -1) {
|
if (clickedNoteIdx !== -1) {
|
||||||
// Click on existing note: drag-move
|
// Click on existing note: drag-move
|
||||||
const clickedNote = notes[clickedNoteIdx];
|
const clickedNote = notes[clickedNoteIdx];
|
||||||
|
let nextSelectedIds;
|
||||||
if (!selectedNoteIds.includes(clickedNote.id)) {
|
if (!selectedNoteIds.includes(clickedNote.id)) {
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
setSelectedNoteIds(prev => [...prev, clickedNote.id]);
|
nextSelectedIds = [...selectedNoteIds, clickedNote.id];
|
||||||
|
setSelectedNoteIds(nextSelectedIds);
|
||||||
} else {
|
} else {
|
||||||
setSelectedNoteIds([clickedNote.id]);
|
nextSelectedIds = [clickedNote.id];
|
||||||
|
setSelectedNoteIds(nextSelectedIds);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
nextSelectedIds = selectedNoteIds;
|
||||||
}
|
}
|
||||||
|
pushToUndo(notes);
|
||||||
|
notesBeforeDragRef.current = JSON.parse(JSON.stringify(notes));
|
||||||
const selectedNotesOffset = notes
|
const selectedNotesOffset = notes
|
||||||
.filter(n => selectedNoteIds.includes(n.id) || n.id === clickedNote.id)
|
.filter(n => nextSelectedIds.includes(n.id))
|
||||||
.map(n => ({
|
.map(n => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
originalStartBeat: n.start_beat,
|
originalStartBeat: n.start_beat,
|
||||||
@@ -4733,7 +4865,8 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
|||||||
selectedNotesOffset: selectedNotesOffset
|
selectedNotesOffset: selectedNotesOffset
|
||||||
});
|
});
|
||||||
} else {
|
} 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 start = getSnapBeat(beat, snapVal);
|
||||||
const initialDur = getSnapDuration(snapVal);
|
const initialDur = getSnapDuration(snapVal);
|
||||||
const noteId = 'note_' + Date.now() + Math.random().toString(36).substr(2, 5);
|
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,
|
startOffsetBeat: start,
|
||||||
startOffsetPitch: pitch,
|
startOffsetPitch: pitch,
|
||||||
drawNoteId: noteId,
|
drawNoteId: noteId,
|
||||||
drawDuration: initialDur
|
drawDuration: initialDur,
|
||||||
|
visitedPitches: [pitch],
|
||||||
|
initialBeat: start,
|
||||||
|
initialPitch: pitch
|
||||||
});
|
});
|
||||||
// Play the note with SoundFont
|
// Play the note with SoundFont
|
||||||
if (window.SonicSF) {
|
if (window.SonicSF) {
|
||||||
@@ -4823,6 +4959,47 @@ const PianoRollTabEditor = ({ st, zoom, bpm, viewportWidth, onClose, onUpdateNot
|
|||||||
if (n.id !== draggedNote.drawNoteId) return n;
|
if (n.id !== draggedNote.drawNoteId) return n;
|
||||||
return { ...n, duration_beats: newDur };
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (draggedNote.mode === 'resize') {
|
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);
|
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 [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 [instrumentSelectorData, setInstrumentSelectorData] = useState(null);
|
||||||
const openInstrumentSelector = trackId => {
|
const openInstrumentSelector = trackId => {
|
||||||
setInstrumentSelectorTrackId(trackId);
|
setInstrumentSelectorTrackId(trackId);
|
||||||
@@ -5435,6 +5618,32 @@ const App = () => {
|
|||||||
const [synthCategory, setSynthCategory] = useState(null); // 'vst' | 'soundfont'
|
const [synthCategory, setSynthCategory] = useState(null); // 'vst' | 'soundfont'
|
||||||
const [selectedSoundFontId, setSelectedSoundFontId] = useState(null);
|
const [selectedSoundFontId, setSelectedSoundFontId] = useState(null);
|
||||||
const [sfPresets, setSfPresets] = useState(null); // presets from SoundFont
|
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 = [
|
const GM_INSTRUMENTS = [
|
||||||
"Acoustic Grand Piano","Bright Acoustic Piano","Electric Grand Piano","Honky-tonk Piano","Electric Piano 1","Electric Piano 2","Harpsichord","Clavi",
|
"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",
|
"Celesta","Glockenspiel","Music Box","Vibraphone","Marimba","Xylophone","Tubular Bells","Dulcimer",
|
||||||
@@ -5458,12 +5667,16 @@ const App = () => {
|
|||||||
if (t.id !== trackId) return t;
|
if (t.id !== trackId) return t;
|
||||||
return { ...t, instrumentId, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName };
|
return { ...t, instrumentId, instrumentProgram: programNumber !== undefined ? programNumber : undefined, instrumentName: displayName };
|
||||||
}));
|
}));
|
||||||
|
setInstrumentDropdownTrackId(null);
|
||||||
|
setInstrumentDropdownBtnRect(null);
|
||||||
setInstrumentSelectorTrackId(null);
|
setInstrumentSelectorTrackId(null);
|
||||||
setSynthCategory(null);
|
setSynthCategory(null);
|
||||||
setSelectedSoundFontId(null);
|
setSelectedSoundFontId(null);
|
||||||
setTimeout(() => lucide.createIcons(), 50);
|
setTimeout(() => lucide.createIcons(), 50);
|
||||||
};
|
};
|
||||||
const setTrackInstrument = (trackId, instrumentId, displayName) => {
|
const setTrackInstrument = (trackId, instrumentId, displayName) => {
|
||||||
|
setInstrumentDropdownTrackId(null);
|
||||||
|
setInstrumentDropdownBtnRect(null);
|
||||||
if (instrumentId && instrumentId.startsWith('sf_')) {
|
if (instrumentId && instrumentId.startsWith('sf_')) {
|
||||||
// Set instrument on track immediately so Synth button shows the name
|
// Set instrument on track immediately so Synth button shows the name
|
||||||
updateActiveTracks(prev => prev.map(t => {
|
updateActiveTracks(prev => prev.map(t => {
|
||||||
@@ -5605,6 +5818,7 @@ const App = () => {
|
|||||||
const [showFxRack, setShowFxRack] = useState(false);
|
const [showFxRack, setShowFxRack] = useState(false);
|
||||||
const [showMidiEvents, setShowMidiEvents] = useState(false);
|
const [showMidiEvents, setShowMidiEvents] = useState(false);
|
||||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
|
const [rightSidebarWidth, setRightSidebarWidth] = useState(320);
|
||||||
|
const [tcpWidth, setTcpWidth] = useState(320);
|
||||||
const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50);
|
const [mediaExplorerHeight, setMediaExplorerHeight] = useState(50);
|
||||||
const [panelPositions, setPanelPositions] = useState({
|
const [panelPositions, setPanelPositions] = useState({
|
||||||
export: 'bottom',
|
export: 'bottom',
|
||||||
@@ -6072,6 +6286,24 @@ const App = () => {
|
|||||||
document.addEventListener('mouseup', onUp);
|
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) ──
|
// ── Right Sidebar Row Resizer (Media Explorer / AI Panel) ──
|
||||||
const startRowResize = e => {
|
const startRowResize = e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -6851,17 +7083,19 @@ const App = () => {
|
|||||||
const secondsPerBar = secondsPerBeat * 4;
|
const secondsPerBar = secondsPerBeat * 4;
|
||||||
const durationSec = (tab.length_bars || 16.0) * secondsPerBar;
|
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 => {
|
setTracks(prev => prev.map(t => {
|
||||||
if (!t.sections || t.sections.length === 0) return t;
|
if (!t.sections || t.sections.length === 0) return t;
|
||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
sections: t.sections.map(s => {
|
sections: t.sections.map(s => {
|
||||||
if (s.sectionId !== tab.sectionId) return s;
|
if (s.sectionId !== tab.sectionId && s.id !== tab.sectionId) return s;
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
name: tab.name,
|
name: tab.name,
|
||||||
duration: durationSec,
|
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; }
|
if (existing) { setActiveTab(existing.id); showToast(`Tab "${section.name}" already open.`, 'info'); return; }
|
||||||
const tabId = 'session_' + Date.now();
|
const tabId = 'session_' + Date.now();
|
||||||
const tabName = section.name || 'Section';
|
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,
|
...t,
|
||||||
clips: [],
|
clips: [],
|
||||||
sections: [],
|
sections: [],
|
||||||
@@ -8472,6 +8706,53 @@ const App = () => {
|
|||||||
return () => cancelAnimationFrame(animationFrameIdRef.current);
|
return () => cancelAnimationFrame(animationFrameIdRef.current);
|
||||||
}, [isPlaying, subTabs, isLoopingSelection, selLeft, selRight, selectionMode, localSelectionTrackId, selectionCleared, activeTab]);
|
}, [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 ──
|
// ── Playback ──
|
||||||
const getOrCreateTrackNode = (track, context) => {
|
const getOrCreateTrackNode = (track, context) => {
|
||||||
if (!track) return null;
|
if (!track) return null;
|
||||||
@@ -8484,12 +8765,41 @@ const App = () => {
|
|||||||
const pannerNode = context.createStereoPanner();
|
const pannerNode = context.createStereoPanner();
|
||||||
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
|
pannerNode.pan.setValueAtTime((track.pan ?? 0) / 100, context.currentTime);
|
||||||
pannerNode.connect(context.destination);
|
pannerNode.connect(context.destination);
|
||||||
gainNode.connect(pannerNode);
|
let fxStopFn;
|
||||||
node = { gainNode, pannerNode };
|
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;
|
activeTrackNodesRef.current[track.id] = node;
|
||||||
}
|
}
|
||||||
return node.gainNode;
|
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) => {
|
const playMidiPreviewNote = (pitch, velocity = 0.8, durationMs = 500) => {
|
||||||
if (!window.SonicSF) return;
|
if (!window.SonicSF) return;
|
||||||
@@ -8611,6 +8921,9 @@ const App = () => {
|
|||||||
const isSubPlayable = hasSubSolo ? subTrack.solo : !subTrack.muted;
|
const isSubPlayable = hasSubSolo ? subTrack.solo : !subTrack.muted;
|
||||||
if (!isSubPlayable) return;
|
if (!isSubPlayable) return;
|
||||||
|
|
||||||
|
const subNode = getOrCreateSubTrackNode(track, subTrack, context);
|
||||||
|
if (!subNode) return;
|
||||||
|
|
||||||
// 1. Play clips in subTrack
|
// 1. Play clips in subTrack
|
||||||
const subClips = subTrack.clips || [];
|
const subClips = subTrack.clips || [];
|
||||||
subClips.forEach(clip => {
|
subClips.forEach(clip => {
|
||||||
@@ -8630,7 +8943,7 @@ const App = () => {
|
|||||||
const source = context.createBufferSource();
|
const source = context.createBufferSource();
|
||||||
source.buffer = clip.buffer;
|
source.buffer = clip.buffer;
|
||||||
source.playbackRate.value = clip.speed || 1.0;
|
source.playbackRate.value = clip.speed || 1.0;
|
||||||
source.connect(gainNode);
|
source.connect(subNode);
|
||||||
|
|
||||||
if (offsetTime < activeStart) {
|
if (offsetTime < activeStart) {
|
||||||
const delay = activeStart - offsetTime;
|
const delay = activeStart - offsetTime;
|
||||||
@@ -8674,7 +8987,7 @@ const App = () => {
|
|||||||
playDurMs,
|
playDurMs,
|
||||||
startTime,
|
startTime,
|
||||||
program,
|
program,
|
||||||
gainNode
|
subNode
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const remainingDurMs = (notePlayEndMain - offsetTime) * 1000;
|
const remainingDurMs = (notePlayEndMain - offsetTime) * 1000;
|
||||||
@@ -8684,7 +8997,7 @@ const App = () => {
|
|||||||
remainingDurMs,
|
remainingDurMs,
|
||||||
context.currentTime,
|
context.currentTime,
|
||||||
program,
|
program,
|
||||||
gainNode
|
subNode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8864,6 +9177,9 @@ const App = () => {
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
});
|
});
|
||||||
activeSourcesRef.current = [];
|
activeSourcesRef.current = [];
|
||||||
|
Object.values(activeTrackNodesRef.current).forEach(n => {
|
||||||
|
if (n.fxStopFn) n.fxStopFn();
|
||||||
|
});
|
||||||
activeTrackNodesRef.current = {};
|
activeTrackNodesRef.current = {};
|
||||||
if (window.SonicSF) {
|
if (window.SonicSF) {
|
||||||
window.SonicSF.stopAll();
|
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", {
|
}, activeTab === 'main' || sessionTabs.some(s => s.id === activeTab) ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||||
ref: tcpContainerRef,
|
ref: tcpContainerRef,
|
||||||
onScroll: handleTCPScroll,
|
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: {
|
style: {
|
||||||
|
width: tcpWidth + 'px',
|
||||||
scrollbarWidth: 'none',
|
scrollbarWidth: 'none',
|
||||||
msOverflowStyle: 'none'
|
msOverflowStyle: 'none'
|
||||||
}
|
}
|
||||||
@@ -13991,10 +14308,10 @@ const App = () => {
|
|||||||
title: "ARM (Record)",
|
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'}`
|
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", {
|
}, /*#__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",
|
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'}`
|
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" })), /*#__PURE__*/React.createElement("button", {
|
}, /*#__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 => {
|
onClick: e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleTrackMonitor(track.id);
|
toggleTrackMonitor(track.id);
|
||||||
@@ -14109,10 +14426,7 @@ const App = () => {
|
|||||||
"data-lucide": "upload",
|
"data-lucide": "upload",
|
||||||
className: "w-3 h-3"
|
className: "w-3 h-3"
|
||||||
})), " File"), /*#__PURE__*/React.createElement("button", {
|
})), " File"), /*#__PURE__*/React.createElement("button", {
|
||||||
onClick: e => {
|
onClick: e => { e.stopPropagation(); setFxSelectorTrackId(track.id); },
|
||||||
e.stopPropagation();
|
|
||||||
showToast('FX panel for track ' + track.id, 'info');
|
|
||||||
},
|
|
||||||
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"
|
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", {
|
}, /*#__PURE__*/React.createElement("span", {
|
||||||
className: "inline-flex items-center shrink-0"
|
className: "inline-flex items-center shrink-0"
|
||||||
@@ -14121,17 +14435,15 @@ const App = () => {
|
|||||||
className: "w-3 h-3"
|
className: "w-3 h-3"
|
||||||
})), " FX: ", /*#__PURE__*/React.createElement("span", {
|
})), " FX: ", /*#__PURE__*/React.createElement("span", {
|
||||||
className: "text-zinc-500 font-normal"
|
className: "text-zinc-500 font-normal"
|
||||||
}, " None")), /*#__PURE__*/React.createElement("button", {
|
}, track.fxType || "None")), /*#__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(''); },
|
||||||
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"
|
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", {
|
}, /*#__PURE__*/React.createElement("span", {
|
||||||
className: "inline-flex items-center shrink-0"
|
className: "inline-flex items-center shrink-0"
|
||||||
}, /*#__PURE__*/React.createElement("i", {
|
}, /*#__PURE__*/React.createElement("i", {
|
||||||
"data-lucide": "music",
|
"data-lucide": "music",
|
||||||
className: "w-3 h-3"
|
className: "w-3 h-3"
|
||||||
})), " Synth: ", /*#__PURE__*/React.createElement("span", {
|
})), /*#__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", {
|
||||||
className: "text-zinc-500 font-normal"
|
|
||||||
}, track.instrumentName || track.instrumentId || "None"))), /*#__PURE__*/React.createElement("div", {
|
|
||||||
onMouseDown: e => handleTrackResizeMouseDown(e, track.id),
|
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",
|
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()
|
onClick: e => e.stopPropagation()
|
||||||
@@ -14139,6 +14451,9 @@ const App = () => {
|
|||||||
})), /*#__PURE__*/React.createElement("div", {
|
})), /*#__PURE__*/React.createElement("div", {
|
||||||
className: "h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0"
|
className: "h-[48px] border-r border-zinc-900 bg-[#1e1e1e]/50 shrink-0"
|
||||||
})), /*#__PURE__*/React.createElement("div", {
|
})), /*#__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,
|
ref: handleTimelineWrapperRef,
|
||||||
onScroll: handleTimelineScroll,
|
onScroll: handleTimelineScroll,
|
||||||
className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"
|
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);
|
})() : (st.buffer && 'duration' in st.buffer ? st.buffer.duration : 4.0);
|
||||||
const subTabTimelineWidth = Math.max(zoom * subTabDuration, viewportWidth);
|
const subTabTimelineWidth = Math.max(zoom * subTabDuration, viewportWidth);
|
||||||
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
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",
|
className: "shrink-0 z-30 bg-[#262626] overflow-y-scroll no-scrollbar flex flex-col border-r border-zinc-900",
|
||||||
style: {
|
style: {
|
||||||
scrollbarWidth: 'none',
|
width: tcpWidth + 'px',
|
||||||
msOverflowStyle: 'none'
|
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("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", {
|
}, /*#__PURE__*/React.createElement("span", {
|
||||||
className: "text-xs font-bold text-zinc-500 uppercase"
|
className: "text-xs font-bold text-zinc-500 uppercase"
|
||||||
}, "Sub-Tab"), /*#__PURE__*/React.createElement("button", {
|
}, "Sub-Tab"), /*#__PURE__*/React.createElement("button", {
|
||||||
@@ -14664,6 +14980,9 @@ const App = () => {
|
|||||||
})), " Save"))) : /*#__PURE__*/React.createElement("div", {
|
})), " Save"))) : /*#__PURE__*/React.createElement("div", {
|
||||||
className: "flex-1 flex items-center justify-center text-xs text-zinc-500"
|
className: "flex-1 flex items-center justify-center text-xs text-zinc-500"
|
||||||
}, "Track not found")), /*#__PURE__*/React.createElement("div", {
|
}, "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,
|
ref: handleTimelineWrapperRef,
|
||||||
className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"
|
className: "flex-1 relative z-10 overflow-x-auto overflow-y-auto min-w-0"
|
||||||
}, /*#__PURE__*/React.createElement("div", {
|
}, /*#__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.")
|
/*#__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'));
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
root.render(/*#__PURE__*/React.createElement(App, null));
|
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