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) ──
|
||||
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" />
|
||||
|
||||
Reference in New Issue
Block a user