feat: vẽ section item canvas

This commit is contained in:
2026-07-24 20:58:15 +07:00
parent b9eb840b11
commit 9a61a1f616
7 changed files with 447 additions and 76 deletions
+415 -54
View File
@@ -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"}
@@ -0,0 +1 @@
{"original_name": "test.sf2", "uuid": "f5aa67a6-d998-4013-b61a-ff0b6adfe311", "file": "f5aa67a6-d998-4013-b61a-ff0b6adfe311.sf2"}