fix: chỉnh sửa tốc độ của audio clip realtime

This commit is contained in:
2026-07-19 17:11:16 +07:00
parent e1b6f47ad0
commit b39bfbc1bc
+112 -18
View File
@@ -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 (
<canvas
ref={canvasRef}
className="w-full h-full rounded border border-zinc-800 cursor-crosshair"
className="w-full h-full rounded border border-zinc-800"
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenuInternal}
onMouseMove={(e) => {
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;
}}
/>
<div onMouseDown={handleSubTabResizeMouseDown}
className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors" />