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 (