From e1b6f47ad09981e71063d0e4e9aaa094f934fb08 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sun, 19 Jul 2026 16:56:17 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20ch=E1=BB=89nh=20s=E1=BB=ADa=20hi?= =?UTF-8?q?=E1=BB=83n=20th=E1=BB=8B=20c=E1=BB=A7a=20audio=20clip=20trong?= =?UTF-8?q?=20subtab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/templates/index.html | 365 ++++++++++++++++++++++++++------------- 1 file changed, 248 insertions(+), 117 deletions(-) diff --git a/app/templates/index.html b/app/templates/index.html index c87b003..3f73568 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -668,7 +668,7 @@ }; // ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── - const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth }) => { + const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth, color, name }) => { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; @@ -689,10 +689,33 @@ const len = data.length; if (len === 0) return; - // Draw waveform only in viewport range for performance & zero-line accuracy + // Draw clip container (like main session clips) const xStart = 0; const wClip = buffer.duration * zoom; const xEnd = xStart + wClip; + const clipTop = 8; + const clipHeight = h - 16; + const clipColor = color || '#06b6d4'; + + ctx.fillStyle = clipColor + '22'; + ctx.strokeStyle = clipColor; + ctx.lineWidth = 1.5; + if (ctx.roundRect) { + ctx.beginPath(); + ctx.roundRect(xStart, clipTop, wClip, clipHeight, 4); + ctx.fill(); + ctx.stroke(); + } else { + ctx.fillRect(xStart, clipTop, wClip, clipHeight); + ctx.strokeRect(xStart, clipTop, wClip, clipHeight); + } + + // Draw clip label + ctx.fillStyle = '#e4e4e7'; + ctx.font = 'bold 10px sans-serif'; + ctx.fillText(name || 'Audio Clip', xStart + 8, clipTop + 14); + + // Draw waveform inside clip const drawXStart = Math.max(0, Math.floor(xStart)); const drawXEnd = Math.min(w, Math.ceil(xEnd)); const samplesPerPixel = buffer.sampleRate / zoom; @@ -715,8 +738,8 @@ if (val > maxVal) maxVal = val; if (val < minVal) minVal = val; } - const yTop = mid + (minVal * (h * 0.45)); - const yBottom = mid + (maxVal * (h * 0.45)); + const yTop = mid + (minVal * (clipHeight * 0.45)); + const yBottom = mid + (maxVal * (clipHeight * 0.45)); ctx.beginPath(); ctx.moveTo(px, yTop); ctx.lineTo(px, yBottom); @@ -750,24 +773,27 @@ ctx.stroke(); } - }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth]); + }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name]); const handleMouseDown = (e) => { if (e.button === 2) return; // ignore right click - const rect = canvasRef.current.getBoundingClientRect(); - const startX = e.clientX - rect.left; + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const parent = canvas.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; const duration = buffer.duration; - const startTime = (startX / rect.width) * duration; + const mouseX = e.clientX - rect.left + scrollLeft; + const startTime = Math.max(0, Math.min(duration, mouseX / zoom)); // Tool-specific behavior if (activeTool === 'grab') { - // Grab tool: move playhead on click/drag onPlayheadSet(startTime); const handleMouseMove = (moveEvent) => { - const currentX = moveEvent.clientX - rect.left; - const currentTime = Math.max(0, Math.min(duration, (currentX / rect.width) * duration)); - onPlayheadSet(currentTime); + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(duration, currentX / zoom)); + onPlayheadSet(ct); }; const handleMouseUp = () => { @@ -781,29 +807,25 @@ } if (activeTool === 'razor') { - // Razor tool: add a cut marker at click position - // For sub-tab, we can store cut points in the subTab state onPlayheadSet(startTime); showToast(`Cut point at ${formatTime(startTime)}`, 'info'); return; } if (activeTool === 'pen') { - // Pen tool: for volume automation drawing - // For now, just move playhead and show indicator onPlayheadSet(startTime); - canvasRef.current.style.cursor = 'crosshair'; + canvas.style.cursor = 'crosshair'; const handleMouseMove = (moveEvent) => { - const currentX = moveEvent.clientX - rect.left; - const currentTime = Math.max(0, Math.min(duration, (currentX / rect.width) * duration)); - onPlayheadSet(currentTime); + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(duration, currentX / zoom)); + onPlayheadSet(ct); }; const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); - canvasRef.current.style.cursor = 'crosshair'; + canvas.style.cursor = 'crosshair'; }; document.addEventListener('mousemove', handleMouseMove); @@ -816,9 +838,9 @@ onPlayheadSet(startTime); const handleMouseMove = (moveEvent) => { - const currentX = moveEvent.clientX - rect.left; - const currentTime = Math.max(0, Math.min(duration, (currentX / rect.width) * duration)); - onSelectRange(startTime, currentTime); + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const ct = Math.max(0, Math.min(duration, currentX / zoom)); + onSelectRange(startTime, ct); }; const handleMouseUp = () => { @@ -833,16 +855,20 @@ const handleContextMenuInternal = (e) => { e.preventDefault(); e.stopPropagation(); - const rect = canvasRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left; - const clickTime = (x / rect.width) * buffer.duration; + const canvas = canvasRef.current; + const rect = canvas.getBoundingClientRect(); + const parent = canvas.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const x = e.clientX - rect.left + scrollLeft; + const clickTime = Math.max(0, Math.min(buffer.duration, x / zoom)); onContextMenu(e, clickTime); }; return ( @@ -1031,6 +1057,7 @@ const [beginBar, setBeginBar] = useState(1); const [endBar, setEndBar] = useState(1); const [numberBar, setNumberBar] = useState(1); + const [subTabHeight, setSubTabHeight] = useState(96); const [isExporting, setIsExporting] = useState(false); const [soloedTrackId, setSoloedTrackId] = useState(null); const [toastMessage, setToastMessage] = useState(null); @@ -1038,7 +1065,7 @@ const [showExportPanel, setShowExportPanel] = useState(true); const [showAIPanel, setShowAIPanel] = useState(true); const [showSelectionPanel, setShowSelectionPanel] = useState(true); - const [panelPositions, setPanelPositions] = useState({ export: 'bottom', ai: 'bottom', selection: 'right' }); + const [panelPositions, setPanelPositions] = useState({ export: 'bottom', ai: 'bottom', selection: 'bottom' }); const [panelDropZone, setPanelDropZone] = useState(null); const [dragGhostPos, setDragGhostPos] = useState(null); const [dragGhostPanel, setDragGhostPanel] = useState(null); @@ -1630,6 +1657,15 @@ showToast('Vui lòng chọn một khoảng thời gian trên sóng âm.', 'warning'); return; } + + // Check if a subtab for this track+range already exists + const existing = subTabs.find(s => s.trackId === trackId && s.startTime === selLeft && s.endTime === selRight); + if (existing) { + setActiveTab(existing.id); + showToast(`Sub Tab already open.`, 'info'); + return; + } + const sr = t.buffer.sampleRate; const trackStart = t.startTime || 0; const relSelLeft = Math.max(0, selLeft - trackStart); @@ -1666,46 +1702,55 @@ showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); }; - const handleEditClipInSubTab = (trackId, clipId) => { - const track = tracks.find(t => t.id === trackId); - if (!track) return; - - const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ - id: 'default_' + track.id, - buffer: track.buffer, - startTime: track.startTime || 0, - name: track.name - }] : []); - - const clip = clips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + trackId)); - if (!clip || !clip.buffer) return; + const handleEditClipInSubTab = (trackId, clipId) => { + const track = tracks.find(t => t.id === trackId); + if (!track) return; + + const clips = track.clips && track.clips.length > 0 ? track.clips : (track.buffer ? [{ + id: 'default_' + track.id, + buffer: track.buffer, + startTime: track.startTime || 0, + name: track.name + }] : []); + + const clip = clips.find(c => c.id === clipId || (clipId === 'default' && c.id === 'default_' + trackId)); + if (!clip || !clip.buffer) return; - const sr = clip.buffer.sampleRate; - const len = clip.buffer.length; - const ctx = getAudioContext(); - const subBuffer = ctx.createBuffer(1, len, sr); - subBuffer.copyToChannel(clip.buffer.getChannelData(0), 0); + const resolvedClipId = clip.id === 'default' ? 'default_' + trackId : clip.id; + // Check if a subtab for this clip already exists + const existing = subTabs.find(s => s.clipId === resolvedClipId && s.trackId === trackId); + if (existing) { + setActiveTab(existing.id); + showToast(`Sub Tab for "${clip.name}" already open.`, 'info'); + return; + } - const tabId = 'subtab_' + Date.now(); - const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`; + const sr = clip.buffer.sampleRate; + const len = clip.buffer.length; + const ctx = getAudioContext(); + const subBuffer = ctx.createBuffer(1, len, sr); + subBuffer.copyToChannel(clip.buffer.getChannelData(0), 0); - setSubTabs(prev => [...prev, { - id: tabId, - label: tabLabel, - trackId: trackId, - clipId: clip.id === 'default' ? 'default_' + trackId : clip.id, - startTime: clip.startTime, - endTime: clip.startTime + clip.buffer.duration, - buffer: subBuffer, - effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 }, - currentTime: 0, - selectionStart: null, - selectionEnd: null, - isPlaying: false - }]); - setActiveTab(tabId); - showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); - }; + const tabId = 'subtab_' + Date.now(); + const tabLabel = `Edit_${clip.name.replace('.wav','').slice(0,10)}`; + + setSubTabs(prev => [...prev, { + id: tabId, + label: tabLabel, + trackId: trackId, + clipId: resolvedClipId, + startTime: clip.startTime, + endTime: clip.startTime + clip.buffer.duration, + buffer: subBuffer, + effects: { reverse: false, gainDb: 0, fadeInMs: 0, fadeOutMs: 0, normalizeDb: 0, pitch: 0, speedStretch: 100 }, + currentTime: 0, + selectionStart: null, + selectionEnd: null, + isPlaying: false + }]); + setActiveTab(tabId); + showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); + }; // ── Sub Tab: apply effects & merge back (LOOP_EDITOR_2.md §1.2) ── const applySubTab = (tabId) => { @@ -2710,6 +2755,27 @@ } }; + const handleSubTabResizeMouseDown = (e) => { + e.preventDefault(); + e.stopPropagation(); + const startY = e.clientY; + const startHeight = subTabHeight; + + const handleMouseMove = (moveEvent) => { + const deltaY = moveEvent.clientY - startY; + const newHeight = Math.max(48, Math.min(400, startHeight + deltaY)); + setSubTabHeight(newHeight); + }; + + const handleMouseUp = () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + // ── Selection ── const clearLocalSelection = () => { setSelectionMode(null); @@ -2718,36 +2784,38 @@ setLocalSelectionEnd(null); }; - const handleRulerMouseDown = (e) => { - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = mouseX / zoom; - - clearLocalSelection(); - setSelectionMode('global'); - rulerDragStartRef.current = time; - isDraggingRulerRef.current = true; - setSelectionStart(time); - setSelectionEnd(time); - setCurrentTime(time); - }; + const handleRulerMouseDown = (e) => { + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const rawTime = mouseX / zoom; + const time = snapValue !== 'free' ? snapTime(rawTime, snapValue, bpm) : rawTime; + + clearLocalSelection(); + setSelectionMode('global'); + rulerDragStartRef.current = time; + isDraggingRulerRef.current = true; + setSelectionStart(time); + setSelectionEnd(time); + setCurrentTime(time); + }; - // Global Ruler mousemove is tracked via document listener set up in useEffect - useEffect(() => { - const handleMouseMove = (e) => { - if (!isDraggingRulerRef.current) return; - const wrapper = timelineWrapperRef.current; - if (!wrapper) return; - const rect = wrapper.getBoundingClientRect(); - const scrollLeft = wrapper.scrollLeft; - const mouseX = e.clientX - rect.left + scrollLeft; - const time = Math.max(0, Math.min(maxDuration, mouseX / zoom)); - - setSelectionEnd(time); - }; + // Global Ruler mousemove is tracked via document listener set up in useEffect + useEffect(() => { + const handleMouseMove = (e) => { + if (!isDraggingRulerRef.current) return; + const wrapper = timelineWrapperRef.current; + if (!wrapper) return; + const rect = wrapper.getBoundingClientRect(); + const scrollLeft = wrapper.scrollLeft; + const mouseX = e.clientX - rect.left + scrollLeft; + const rawTime = Math.max(0, Math.min(maxDuration, mouseX / zoom)); + const time = snapValueRef.current !== 'free' ? snapTime(rawTime, snapValueRef.current, bpmRef.current) : rawTime; + + setSelectionEnd(time); + }; const handleMouseUp = () => { if (isDraggingRulerRef.current) { isDraggingRulerRef.current = false; @@ -4302,41 +4370,70 @@ }`} title="Bật/Tắt Lặp vùng chọn"> - Snap + Snap -
- Bars: +
+ Bars: { const b = parseInt(e.target.value) || 1; setBeginBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = (b - 1) * beatDuration * 4; - setSelectionStart(t); setSelectionEnd(Math.max(t, selRight || t)); - }} className="w-10 bg-black text-white text-[9px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> - - + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(t); + setSelectionEnd(t + beatDuration * 4); + }} className="w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> + - { const b = parseInt(e.target.value) || 1; setEndBar(b); const beatDuration = 60 / parseInt(bpm || 120); const t = (b - 1) * beatDuration * 4; - setSelectionEnd(t); + setSelectionEnd(t + beatDuration * 4); setNumberBar(b - beginBar + 1); - }} className="w-10 bg-black text-white text-[9px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> - # + }} className="w-14 bg-black text-white text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> + # -
- Start: - {selLeft !== null ? formatTime(selLeft) : '--:--'} - End: - {selRight !== null ? formatTime(selRight) : '--:--'} - Len: - {selLeft !== null && selRight !== null ? formatTime(Math.abs(selRight - selLeft)) : '--:--'} + className="w-14 bg-black text-zinc-400 text-[14px] px-1 py-0.5 rounded border border-zinc-800 text-center font-mono" /> +
+ Start: + { + const parts = e.target.value.split(/[:.]/); + if (parts.length === 3) { + const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(secs); + } + }} + className="w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> + End: + { + const parts = e.target.value.split(/[:.]/); + if (parts.length === 3) { + const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); + setSelectionEnd(secs); + } + }} + className="w-20 bg-black text-zinc-200 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" /> + Len: + { + const parts = e.target.value.split(/[:.]/); + if (parts.length === 3 && selLeft !== null) { + const secs = parseInt(parts[0]) * 60 + parseInt(parts[1]) + parseFloat('0.' + (parts[2] || '0')); + setSelectionEnd(selLeft + secs); + } + }} + className="w-20 bg-black text-amber-300 text-[14px] px-1 py-0.5 rounded border border-zinc-700 text-center font-mono" />
{formatTime(currentTime)}
@@ -4446,6 +4543,36 @@ {selectionStats.length}s +
+
+ Begin Bar + { + const b = parseInt(e.target.value) || 1; + setBeginBar(b); + const beatDuration = 60 / parseInt(bpm || 120); + const t = (b - 1) * beatDuration * 4; + clearLocalSelection(); + setSelectionMode('global'); + setSelectionStart(t); + setSelectionEnd(t + beatDuration * 4); + }} className="w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" /> +
+
+ End Bar + { + const b = parseInt(e.target.value) || 1; + setEndBar(b); + const beatDuration = 60 / parseInt(bpm || 120); + const t = (b - 1) * beatDuration * 4; + setSelectionEnd(t + beatDuration * 4); + setNumberBar(b - beginBar + 1); + }} className="w-full bg-[#242424] text-white text-center font-mono text-[9px] rounded py-0.5 border border-zinc-700 focus:outline-none" /> +
+
+ # Bars + {numberBar} +
+
); return null; @@ -4591,7 +4718,7 @@
{Array.from({ length: Math.ceil(maxDuration) }).map((_, i) => { const sec = i; const x = sec * zoom; - return (
{formatTime(sec)}
); + return (
{formatTime(sec)}
); })}
@@ -4748,10 +4875,10 @@
-
+
{Array.from({ length: Math.ceil(st.buffer ? st.buffer.duration : 0) }).map((_, i) => { const sec = i; const x = sec * zoom; - return (
{formatTime(sec)}
); + return (
{formatTime(sec)}
); })}
@@ -4763,7 +4890,7 @@
{vTrack && ( -
+
setContextMenu({x: e.clientX, y: e.clientY, isSubTab: true, subTabId: st.id, time: clickTime})} zoom={zoom} timelineWidth={subTabTimelineWidth} + color={vTrack.color} + name={vTrack.name} /> +
)} {st.selectionStart !== null && st.selectionEnd !== null && st.selectionEnd > st.selectionStart && (