fix: chỉnh sửa tốc độ của audio clip realtime
This commit is contained in:
+112
-18
@@ -668,8 +668,11 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
|
// ── 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 canvasRef = useRef(null);
|
||||||
|
const isStretchingRef = useRef(false);
|
||||||
|
const stretchStartRef = useRef({ mouseX: 0, originalDuration: 0, originalSpeed: 1.0 });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas || !buffer) return;
|
if (!canvas || !buffer) return;
|
||||||
@@ -691,7 +694,7 @@
|
|||||||
|
|
||||||
// Draw clip container (like main session clips)
|
// Draw clip container (like main session clips)
|
||||||
const xStart = 0;
|
const xStart = 0;
|
||||||
const wClip = buffer.duration * zoom;
|
const wClip = (buffer.duration / speed) * zoom; // speed-adjusted width
|
||||||
const xEnd = xStart + wClip;
|
const xEnd = xStart + wClip;
|
||||||
const clipTop = 8;
|
const clipTop = 8;
|
||||||
const clipHeight = h - 16;
|
const clipHeight = h - 16;
|
||||||
@@ -710,23 +713,27 @@
|
|||||||
ctx.strokeRect(xStart, clipTop, wClip, clipHeight);
|
ctx.strokeRect(xStart, clipTop, wClip, clipHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw clip label
|
// Draw clip label with speed %
|
||||||
ctx.fillStyle = '#e4e4e7';
|
ctx.fillStyle = '#e4e4e7';
|
||||||
ctx.font = 'bold 10px sans-serif';
|
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 drawXStart = Math.max(0, Math.floor(xStart));
|
||||||
const drawXEnd = Math.min(w, Math.ceil(xEnd));
|
const drawXEnd = Math.min(w, Math.ceil(xEnd));
|
||||||
const samplesPerPixel = buffer.sampleRate / zoom;
|
const samplesPerPixel = (buffer.sampleRate / zoom) * speed;
|
||||||
|
|
||||||
ctx.strokeStyle = '#6ee7b7';
|
ctx.strokeStyle = '#6ee7b7';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
const mid = h / 2;
|
const mid = h / 2;
|
||||||
|
|
||||||
for (let px = drawXStart; px < drawXEnd; px++) {
|
for (let px = drawXStart; px < drawXEnd; px++) {
|
||||||
const time = px / zoom;
|
const timeInClip = (px / zoom) * speed;
|
||||||
const sampleIdx = Math.floor(time * buffer.sampleRate);
|
const sampleIdx = Math.floor(timeInClip * buffer.sampleRate);
|
||||||
const chunkSize = Math.max(1, Math.floor(samplesPerPixel));
|
const chunkSize = Math.max(1, Math.floor(samplesPerPixel));
|
||||||
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
const chunkStart = Math.max(0, sampleIdx - Math.floor(chunkSize / 2));
|
||||||
const chunkEnd = Math.min(len, chunkStart + chunkSize);
|
const chunkEnd = Math.min(len, chunkStart + chunkSize);
|
||||||
@@ -746,6 +753,18 @@
|
|||||||
ctx.stroke();
|
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
|
// Highlight selection if active
|
||||||
if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) {
|
if (selectionStart !== null && selectionEnd !== null && selectionStart !== selectionEnd) {
|
||||||
const left = Math.min(selectionStart, selectionEnd);
|
const left = Math.min(selectionStart, selectionEnd);
|
||||||
@@ -763,7 +782,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Draw playhead
|
// Draw playhead
|
||||||
if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration) {
|
if (currentTime !== null && currentTime >= 0 && currentTime <= buffer.duration / speed) {
|
||||||
const playheadPx = currentTime * zoom;
|
const playheadPx = currentTime * zoom;
|
||||||
ctx.strokeStyle = '#ef4444';
|
ctx.strokeStyle = '#ef4444';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
@@ -773,10 +792,10 @@
|
|||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
}, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name]);
|
}, [buffer, currentTime, selectionStart, selectionEnd, zoom, timelineWidth, color, name, speed]);
|
||||||
|
|
||||||
const handleMouseDown = (e) => {
|
const handleMouseDown = (e) => {
|
||||||
if (e.button === 2) return; // ignore right click
|
if (e.button === 2) return;
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const parent = canvas.parentElement;
|
const parent = canvas.parentElement;
|
||||||
@@ -786,6 +805,41 @@
|
|||||||
const mouseX = e.clientX - rect.left + scrollLeft;
|
const mouseX = e.clientX - rect.left + scrollLeft;
|
||||||
const startTime = Math.max(0, Math.min(duration, mouseX / zoom));
|
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
|
// Tool-specific behavior
|
||||||
if (activeTool === 'grab') {
|
if (activeTool === 'grab') {
|
||||||
onPlayheadSet(startTime);
|
onPlayheadSet(startTime);
|
||||||
@@ -868,9 +922,23 @@
|
|||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
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}
|
onMouseDown={handleMouseDown}
|
||||||
onContextMenu={handleContextMenuInternal}
|
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,
|
currentTime: 0,
|
||||||
selectionStart: null,
|
selectionStart: null,
|
||||||
selectionEnd: null,
|
selectionEnd: null,
|
||||||
isPlaying: false
|
isPlaying: false,
|
||||||
|
speed: 1.0
|
||||||
}]);
|
}]);
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
|
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
|
||||||
@@ -1746,7 +1815,8 @@
|
|||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
selectionStart: null,
|
selectionStart: null,
|
||||||
selectionEnd: null,
|
selectionEnd: null,
|
||||||
isPlaying: false
|
isPlaying: false,
|
||||||
|
speed: 1.0
|
||||||
}]);
|
}]);
|
||||||
setActiveTab(tabId);
|
setActiveTab(tabId);
|
||||||
showToast(`Đã tạo Sub Tab: ${tabLabel}. Chỉnh sửa và Apply để gộp lại.`, 'info');
|
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();
|
const source = context.createBufferSource();
|
||||||
source.buffer = edBuffer;
|
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();
|
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);
|
source.connect(gainNode);
|
||||||
gainNode.connect(context.destination);
|
gainNode.connect(pannerNode);
|
||||||
|
pannerNode.connect(context.destination);
|
||||||
|
|
||||||
source.start(context.currentTime, offsetTime);
|
source.start(context.currentTime, offsetTime);
|
||||||
activeSourcesRef.current = [source];
|
activeSourcesRef.current = [source];
|
||||||
|
activeTrackNodesRef.current[st.trackId] = { gainNode, pannerNode, source };
|
||||||
startOffsetTimeRef.current = offsetTime;
|
startOffsetTimeRef.current = offsetTime;
|
||||||
startAudioTimeRef.current = context.currentTime;
|
startAudioTimeRef.current = context.currentTime;
|
||||||
};
|
};
|
||||||
@@ -2503,7 +2585,9 @@
|
|||||||
if (!st || !st.isPlaying || !st.buffer) return;
|
if (!st || !st.isPlaying || !st.buffer) return;
|
||||||
const context = getAudioContext();
|
const context = getAudioContext();
|
||||||
const elapsed = context.currentTime - startAudioTimeRef.current;
|
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
|
// Loop sub-tab selection
|
||||||
if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd) {
|
if (st.selectionStart !== null && st.selectionEnd !== null && st.selectionStart !== st.selectionEnd) {
|
||||||
@@ -2522,7 +2606,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updatedTime >= st.buffer.duration) {
|
if (updatedTime >= effectiveDuration) {
|
||||||
stopAllPlayback();
|
stopAllPlayback();
|
||||||
if (isLoopingSelection) {
|
if (isLoopingSelection) {
|
||||||
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
|
setSubTabs(prev => prev.map(s => s.id === activeTab ? {
|
||||||
@@ -4906,6 +4990,16 @@
|
|||||||
timelineWidth={subTabTimelineWidth}
|
timelineWidth={subTabTimelineWidth}
|
||||||
color={vTrack.color}
|
color={vTrack.color}
|
||||||
name={vTrack.name}
|
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}
|
<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" />
|
className="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize z-30 hover:bg-purple-500/50 transition-colors" />
|
||||||
|
|||||||
Reference in New Issue
Block a user