From b39bfbc1bce4481aa5335e69632195ce06a76f0d Mon Sep 17 00:00:00 2001 From: 3dtours Date: Sun, 19 Jul 2026 17:11:16 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20ch=E1=BB=89nh=20s=E1=BB=ADa=20t=E1=BB=91?= =?UTF-8?q?c=20=C4=91=E1=BB=99=20c=E1=BB=A7a=20audio=20clip=20realtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/templates/index.html | 136 +++++++++++++++++++++++++++++++++------ 1 file changed, 115 insertions(+), 21 deletions(-) diff --git a/app/templates/index.html b/app/templates/index.html index 3f73568..8c52b3a 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -668,8 +668,11 @@ }; // ── 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, color, name }) => { + const SubTabWaveform = ({ buffer, subTabId, activeTab, currentTime, selectionStart, selectionEnd, onSelectRange, onPlayheadSet, onContextMenu, activeTool, zoom, timelineWidth, color, name, speed = 1.0, onSpeedChange }) => { const canvasRef = useRef(null); + const isStretchingRef = useRef(false); + const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 }); + useEffect(() => { const canvas = canvasRef.current; if (!canvas || !buffer) return; @@ -691,7 +694,7 @@ // Draw clip container (like main session clips) const xStart = 0; - const wClip = buffer.duration * zoom; + const wClip = (buffer.duration / speed) * zoom; // speed-adjusted width const xEnd = xStart + wClip; const clipTop = 8; const clipHeight = h - 16; @@ -710,23 +713,27 @@ ctx.strokeRect(xStart, clipTop, wClip, clipHeight); } - // Draw clip label + // Draw clip label with speed % ctx.fillStyle = '#e4e4e7'; ctx.font = 'bold 10px sans-serif'; - ctx.fillText(name || 'Audio Clip', xStart + 8, clipTop + 14); + let displayName = name || 'Audio Clip'; + if (speed !== 1.0) { + displayName += ` (${Math.round(speed * 100)}%)`; + } + ctx.fillText(displayName, xStart + 8, clipTop + 14); - // Draw waveform inside clip + // Draw waveform inside clip (speed-adjusted) const drawXStart = Math.max(0, Math.floor(xStart)); const drawXEnd = Math.min(w, Math.ceil(xEnd)); - const samplesPerPixel = buffer.sampleRate / zoom; + const samplesPerPixel = (buffer.sampleRate / zoom) * speed; ctx.strokeStyle = '#6ee7b7'; ctx.lineWidth = 1; const mid = h / 2; for (let px = drawXStart; px < drawXEnd; px++) { - const time = px / zoom; - const sampleIdx = Math.floor(time * buffer.sampleRate); + const timeInClip = (px / zoom) * speed; + const sampleIdx = Math.floor(timeInClip * buffer.sampleRate); const chunkSize = Math.max(1, Math.floor(samplesPerPixel)); const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2)); const chunkEnd = Math.min(len, chunkStart + chunkSize); @@ -746,6 +753,18 @@ ctx.stroke(); } + // Draw right-edge stretch handle indicator + if (wClip > 0 && wClip < w) { + ctx.strokeStyle = clipColor; + ctx.lineWidth = 1.5; + ctx.setLineDash([3, 3]); + ctx.beginPath(); + ctx.moveTo(wClip + xStart, clipTop); + ctx.lineTo(wClip + xStart, clipTop + clipHeight); + ctx.stroke(); + ctx.setLineDash([]); + } + // Highlight selection if active if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) { const left = Math.min(selectionStart, selectionEnd); @@ -763,7 +782,7 @@ } // Draw playhead - if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration) { + if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration / speed) { const playheadPx = currentTime * zoom; ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; @@ -773,10 +792,10 @@ ctx.stroke(); } - }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name]); + }, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed]); const handleMouseDown = (e) => { - if (e.button === 2) return; // ignore right click + if (e.button === 2) return; const canvas = canvasRef.current; const rect = canvas.getBoundingClientRect(); const parent = canvas.parentElement; @@ -786,6 +805,41 @@ const mouseX = e.clientX - rect.left + scrollLeft; const startTime = Math.max(0, Math.min(duration, mouseX / zoom)); + // Alt+Click near right edge → speed stretch + if (e.altKey && onSpeedChange) { + const clipRightEdge = (duration / speed) * zoom; + const tolerance = 8; + if (Math.abs(mouseX - clipRightEdge) <= tolerance) { + isStretchingRef.current = true; + stretchStartRef.current = { + mouseX, + originalDuration: duration, + originalSpeed: speed + }; + canvas.style.cursor = 'ew-resize'; + + const handleMouseMove = (moveEvent) => { + const currentX = moveEvent.clientX - rect.left + scrollLeft; + const wClipPx = (stretchStartRef.current.originalDuration / stretchStartRef.current.originalSpeed) * zoom; + const deltaX = currentX - stretchStartRef.current.mouseX; + const newWClip = Math.max(10, wClipPx + deltaX); + const newSpeed = stretchStartRef.current.originalDuration / (newWClip / zoom); + if (onSpeedChange) onSpeedChange(Math.max(0.05, Math.min(10, newSpeed))); + }; + + const handleMouseUp = () => { + isStretchingRef.current = false; + canvas.style.cursor = 'default'; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return; + } + } + // Tool-specific behavior if (activeTool === 'grab') { onPlayheadSet(startTime); @@ -868,9 +922,23 @@ return ( { + if (canvasRef.current && e.altKey && onSpeedChange) { + const rect = canvasRef.current.getBoundingClientRect(); + const parent = canvasRef.current.parentElement; + const scrollContainer = parent ? parent.parentElement : null; + const scrollLeft = scrollContainer ? scrollContainer.scrollLeft : 0; + const mx = e.clientX - rect.left + scrollLeft; + const wClip = (buffer.duration / speed) * zoom; + const tolerance = 8; + canvasRef.current.style.cursor = (Math.abs(mx - wClip) <= tolerance && !isStretchingRef.current) ? 'ew-resize' : 'crosshair'; + } else if (canvasRef.current && !isStretchingRef.current) { + canvasRef.current.style.cursor = 'crosshair'; + } + }} /> ); }; @@ -1696,7 +1764,8 @@ currentTime: 0, selectionStart: null, selectionEnd: null, - isPlaying: false + isPlaying: false, + speed: 1.0 }]); setActiveTab(tabId); showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); @@ -1746,7 +1815,8 @@ currentTime: 0, selectionStart: null, selectionEnd: null, - isPlaying: false + isPlaying: false, + speed: 1.0 }]); setActiveTab(tabId); showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info'); @@ -2484,15 +2554,27 @@ const source = context.createBufferSource(); source.buffer = edBuffer; - + source.playbackRate.value = st.speed || 1.0; + + // Look up track for volume/pan + const subTrack = tracks.find(t => t.id === st.trackId); + const trackVolDb = subTrack ? (subTrack.volumeDb ?? 0) : 0; + const trackPan = subTrack ? (subTrack.pan ?? 0) : 0; + const gainNode = context.createGain(); - gainNode.gain.setValueAtTime(1.0, context.currentTime); - + const volLinear = trackVolDb <= -50 ? 0 : Math.pow(10, trackVolDb / 20); + gainNode.gain.setValueAtTime(volLinear, context.currentTime); + + const pannerNode = context.createStereoPanner(); + pannerNode.pan.setValueAtTime(trackPan / 100, context.currentTime); + source.connect(gainNode); - gainNode.connect(context.destination); - + gainNode.connect(pannerNode); + pannerNode.connect(context.destination); + source.start(context.currentTime, offsetTime); activeSourcesRef.current = [source]; + activeTrackNodesRef.current[st.trackId] = { gainNode, pannerNode, source }; startOffsetTimeRef.current = offsetTime; startAudioTimeRef.current = context.currentTime; }; @@ -2503,7 +2585,9 @@ if (!st || !st.isPlaying || !st.buffer) return; const context = getAudioContext(); const elapsed = context.currentTime - startAudioTimeRef.current; - const updatedTime = startOffsetTimeRef.current + elapsed; + const speedFactor = st.speed || 1.0; + const updatedTime = startOffsetTimeRef.current + elapsed * speedFactor; + const effectiveDuration = st.buffer.duration / speedFactor; // Loop sub-tab selection if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd) { @@ -2522,7 +2606,7 @@ } } - if (updatedTime >= st.buffer.duration) { + if (updatedTime >= effectiveDuration) { stopAllPlayback(); if (isLoopingSelection) { setSubTabs(prev => prev.map(s => s.id === activeTab ? { @@ -4906,6 +4990,16 @@ timelineWidth={subTabTimelineWidth} color={vTrack.color} name={vTrack.name} + speed={st.speed || 1.0} + onSpeedChange={(newSpeed) => { + setSubTabs(prev => prev.map(s => s.id === st.id ? { + ...s, + speed: newSpeed, + label: s.label.replace(/\s\(\d+%\)$/, '') + ` (${Math.round(newSpeed * 100)}%)` + } : s)); + const n = activeTrackNodesRef.current[st.trackId]; + if (n && n.source) n.source.playbackRate.value = newSpeed; + }} />