Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions src/components/timeline/ClipBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export function ClipBlock({ clip, track }: ClipBlockProps) {
const [dragGhost, setDragGhost] = useState<DragGhostInfo | null>(null);
const [scissorLine, setScissorLine] = useState<number | null>(null);
const [hoveredResizeEdge, setHoveredResizeEdge] = useState<'left' | 'right' | null>(null);
const [hoverSeekX, setHoverSeekX] = useState<number | null>(null);
const scissorRef = useRef(false);
const suppressContextMenuRef = useRef(false);

Expand Down Expand Up @@ -563,8 +564,16 @@ export function ClipBlock({ clip, track }: ClipBlockProps) {
e.stopPropagation();
if (dragRef.current) return;
setCtxMenu(null);
selectClip(clip.id, e.metaKey || e.ctrlKey);
}, [clip.id, selectClip]);
const isMultiSelect = e.metaKey || e.ctrlKey;
selectClip(clip.id, isMultiSelect);
useUIStore.getState().selectTrack(track.id, isMultiSelect);
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling selectTrack() here will set lastSelectionContext to 'tracks' (see uiStore.selectTrack), even though the user clicked a clip. That can change subsequent shortcut behavior (e.g. Cmd+A selecting tracks instead of clips). Consider selecting the parent track in a way that preserves the clip selection context (e.g. a dedicated helper that updates selectedTrackIds without touching lastSelectionContext, or re-setting the context back to 'clips' after selecting the track).

Suggested change
useUIStore.getState().selectTrack(track.id, isMultiSelect);
useUIStore.getState().selectTrack(track.id, isMultiSelect);
// Preserve clip selection context even though we also select the parent track
useUIStore.setState({ lastSelectionContext: 'clips' });

Copilot uses AI. Check for mistakes.
if (!isMultiSelect) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const relX = e.clientX - rect.left;
const clickTime = clip.startTime + relX / pixelsPerSecond;
useTransportStore.getState().seek(clickTime);
}
}, [clip.id, clip.startTime, track.id, selectClip, pixelsPerSecond]);

const handleDoubleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
Expand Down Expand Up @@ -605,9 +614,11 @@ export function ClipBlock({ clip, track }: ClipBlockProps) {

if (relX <= EDGE_HANDLE_PX || relX >= rect.width - EDGE_HANDLE_PX) {
setHoveredResizeEdge(relX <= EDGE_HANDLE_PX ? 'left' : 'right');
setHoverSeekX(null);
setResizeCursor(true);
} else {
setHoveredResizeEdge(null);
setHoverSeekX(relX);
currentTarget.style.cursor = altKey ? 'ew-resize' : 'grab';
setResizeCursor(false);
}
Expand All @@ -624,6 +635,7 @@ export function ClipBlock({ clip, track }: ClipBlockProps) {
const handleMouseLeaveLocal = useCallback((e: React.MouseEvent) => {
const el = e.currentTarget as HTMLElement;
setHoveredResizeEdge(null);
setHoverSeekX(null);
el.style.cursor = '';
setResizeCursor(false);
}, [setResizeCursor]);
Expand Down Expand Up @@ -978,6 +990,19 @@ export function ClipBlock({ clip, track }: ClipBlockProps) {
</div>
)}

{hoverSeekX !== null && (
<div
className="absolute top-0 bottom-0 pointer-events-none z-20"
data-testid="hover-seek-line"
style={{
left: hoverSeekX,
width: 1,
background: 'rgba(255, 255, 255, 0.18)',
boxShadow: '0 0 3px rgba(255, 255, 255, 0.10), 0 0 8px rgba(255, 255, 255, 0.05)',
}}
/>
)}

{scissorLine !== null && (
<div
className="absolute top-0 bottom-0 w-px pointer-events-none z-30"
Expand Down
181 changes: 123 additions & 58 deletions src/components/timeline/ClipWaveform.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { MidiClipData, StretchMode } from '../../types/project';
import { getClipSourceSpan, getClipWaveformLayout } from '../../utils/clipAudio';
import { PEAK_STRIDE } from '../../utils/waveformPeaks';

interface ClipWaveformProps {
peaks: number[] | null;
Expand Down Expand Up @@ -37,15 +38,39 @@ export function ClipWaveform({
stretchMode,
};
const waveformLayout = getClipWaveformLayout(clipWindow, contentWidth);
const peakSlice = getVisiblePeakSlice(peaks, audioDuration, audioOffset, getClipSourceSpan(clipWindow));

if (!peaks || peakSlice.numBars === 0 || contentWidth <= 0 || waveformLayout.widthPx <= 0) {
if (!peaks || peaks.length === 0 || contentWidth <= 0 || waveformLayout.widthPx <= 0) {
return null;
}

const logicalPeakCount = Math.floor(peaks.length / PEAK_STRIDE);
if (logicalPeakCount === 0) {
// Legacy mono fallback: old peaks with no stride structure
return null;
}

const peakSlice = getVisiblePeakSlice(logicalPeakCount, audioDuration, audioOffset, getClipSourceSpan(clipWindow));
if (peakSlice.numBars === 0) return null;
Comment on lines +46 to +53
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logicalPeakCount = Math.floor(peaks.length / PEAK_STRIDE) will be non-zero for legacy mono peak arrays (e.g. 1024 old-style peaks), so this code will silently interpret legacy data as interleaved min/max and render an incorrect waveform. If you need backward compatibility with existing saved projects/assets, add an explicit format check (e.g. peaks.length === CLIP_WAVEFORM_PEAK_COUNT or a stored version) and convert legacy magnitudes into the new [max,min,max,min] structure before rendering.

Copilot uses AI. Check for mistakes.

const columnCount = Math.max(1, Math.floor(waveformLayout.widthPx));
const columnWidth = waveformLayout.widthPx / columnCount;

// Each channel occupies its own vertical half.
// Left channel: y = 0..50, center at y = 25
// Right channel: y = 50..100, center at y = 75
const leftPath = buildChannelPath(
peaks, peakSlice, columnCount, columnWidth, waveformLayout.leftPx,
0, // channelOffset in stride: 0 = Lmax, 1 = Lmin
25, // centerY for left channel
23, // maxAmplitude (px in SVG units, leaves 2px padding)
);
const rightPath = buildChannelPath(
peaks, peakSlice, columnCount, columnWidth, waveformLayout.leftPx,
2, // channelOffset in stride: 2 = Rmax, 3 = Rmin
75, // centerY for right channel
23,
);

return (
<div className="absolute inset-0 flex items-center overflow-hidden">
<svg
Expand All @@ -55,27 +80,107 @@ export function ClipWaveform({
preserveAspectRatio="none"
className={opacityClassName}
>
{Array.from({ length: columnCount }, (_, index) => {
const peak = getPeakForColumn(peaks, peakSlice, index, columnCount);
const height = peak * 80;

return (
<rect
key={index}
x={waveformLayout.leftPx + index * columnWidth}
y={50 - height / 2}
width={Math.max(columnWidth, 1)}
height={Math.max(height, 1)}
fill={color}
rx={0.4}
/>
);
})}
{/* Thin center divider between channels */}
<line
x1={waveformLayout.leftPx}
y1={50}
x2={waveformLayout.leftPx + waveformLayout.widthPx}
y2={50}
stroke={color}
strokeOpacity={0.2}
strokeWidth={0.5}
/>
<path d={leftPath} fill={color} data-testid="waveform-left-channel" />
<path d={rightPath} fill={color} data-testid="waveform-right-channel" />
</svg>
</div>
);
}

/**
* Build an SVG path for one channel's waveform.
* Draws the positive envelope (max) left-to-right, then negative envelope (min) right-to-left,
* creating a filled shape around the channel's center line.
*/
function buildChannelPath(
peaks: number[],
peakSlice: { startPeakIdx: number; numBars: number },
columnCount: number,
columnWidth: number,
leftPx: number,
channelOffset: number, // 0 for left (Lmax at +0, Lmin at +1), 2 for right (Rmax at +2, Rmin at +3)
centerY: number,
maxAmplitude: number,
): string {
// Upper contour (max values, positive peaks going upward from center)
const upperPoints: string[] = [];
// Lower contour (min values, negative peaks going downward from center)
const lowerPoints: string[] = [];

for (let i = 0; i < columnCount; i++) {
const x = leftPx + (i + 0.5) * columnWidth;
const { max, min } = getMinMaxForColumn(peaks, peakSlice, i, columnCount, channelOffset);
// max >= 0, maps upward from center; min <= 0, maps downward from center
const yTop = centerY - max * maxAmplitude;
const yBottom = centerY - min * maxAmplitude; // min is negative, so this goes below center
upperPoints.push(`${x} ${yTop}`);
lowerPoints.push(`${x} ${yBottom}`);
}

// Build closed path: upper left-to-right, then lower right-to-left
return `M ${upperPoints[0]} L ${upperPoints.join(' L ')} L ${lowerPoints.reverse().join(' L ')} Z`;
}

function getVisiblePeakSlice(
logicalPeakCount: number,
audioDuration: number,
audioOffset: number,
sourceSpan: number,
) {
if (logicalPeakCount === 0 || audioDuration <= 0) {
return { startPeakIdx: 0, numBars: 0 };
}

const startPeakIdx = Math.floor((audioOffset / audioDuration) * logicalPeakCount);
const visibleAudioSec = Math.min(sourceSpan, Math.max(0, audioDuration - audioOffset));
const endPeakIdx = Math.min(
Math.ceil(((audioOffset + visibleAudioSec) / audioDuration) * logicalPeakCount),
logicalPeakCount,
);

return {
startPeakIdx,
numBars: Math.max(0, endPeakIdx - startPeakIdx),
};
}
Comment on lines +134 to +155
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getVisiblePeakSlice doesn’t clamp audioOffset into [0, audioDuration], so startPeakIdx can become negative or exceed logicalPeakCount (e.g. from rounding or invalid clip state). That can lead to out-of-range peak indices and empty/incorrect rendering. Clamp audioOffset (and the derived startPeakIdx) to valid bounds before computing the slice.

Copilot uses AI. Check for mistakes.

/**
* For a given display column, find the min and max sample values across
* the corresponding peak range for a specific channel.
*/
function getMinMaxForColumn(
peaks: number[],
peakSlice: { startPeakIdx: number; numBars: number },
columnIndex: number,
columnCount: number,
channelOffset: number, // 0 for L, 2 for R
): { max: number; min: number } {
const start = peakSlice.startPeakIdx + Math.floor((columnIndex / columnCount) * peakSlice.numBars);
const end = peakSlice.startPeakIdx + Math.ceil(((columnIndex + 1) / columnCount) * peakSlice.numBars);
let max = 0;
let min = 0;

for (let i = start; i < end; i++) {
const idx = i * PEAK_STRIDE + channelOffset;
const peakMax = peaks[idx] ?? 0;
const peakMin = peaks[idx + 1] ?? 0;
if (peakMax > max) max = peakMax;
if (peakMin < min) min = peakMin;
}

return { max, min };
}

interface ClipMidiThumbnailProps {
midiData: MidiClipData;
width: number;
Expand Down Expand Up @@ -111,43 +216,3 @@ export function ClipMidiThumbnail({ midiData, width, duration, bpm, color }: Cli
</div>
);
}

function getVisiblePeakSlice(
peaks: number[] | null,
audioDuration: number,
audioOffset: number,
sourceSpan: number,
) {
if (!peaks || peaks.length === 0 || audioDuration <= 0) {
return { startPeakIdx: 0, numBars: 0 };
}

const startPeakIdx = Math.floor((audioOffset / audioDuration) * peaks.length);
const visibleAudioSec = Math.min(sourceSpan, Math.max(0, audioDuration - audioOffset));
const endPeakIdx = Math.min(
Math.ceil(((audioOffset + visibleAudioSec) / audioDuration) * peaks.length),
peaks.length,
);

return {
startPeakIdx,
numBars: Math.max(0, endPeakIdx - startPeakIdx),
};
}

function getPeakForColumn(
peaks: number[],
peakSlice: { startPeakIdx: number; numBars: number },
columnIndex: number,
columnCount: number,
) {
const start = peakSlice.startPeakIdx + Math.floor((columnIndex / columnCount) * peakSlice.numBars);
const end = peakSlice.startPeakIdx + Math.ceil(((columnIndex + 1) / columnCount) * peakSlice.numBars);
let maxPeak = 0;

for (let index = start; index < Math.min(end, peaks.length); index += 1) {
maxPeak = Math.max(maxPeak, peaks[index] ?? 0);
}

return maxPeak;
}
30 changes: 18 additions & 12 deletions src/components/timeline/GridOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useUIStore } from '../../store/uiStore';
import { getBeatDuration, getBarDuration } from '../../utils/time';
import { beatToTime, getBeatAtBar, getTimeSignatureAtBar, getTimeSignatureBeatLength } from '../../utils/tempoMap';
import { getTimelineVisualDuration } from '../../utils/timelineZoom';
import { useMetaKeyDown } from '../../hooks/useMetaKeyDown';

/**
* Adaptive grid: resolution auto-adjusts based on zoom level.
Expand All @@ -23,6 +24,7 @@ export function GridOverlay() {
const project = useProjectStore((s) => s.project);
const pixelsPerSecond = useUIStore((s) => s.pixelsPerSecond);
const timelineViewportWidth = useUIStore((s) => s.timelineViewportWidth);
const isMetaDown = useMetaKeyDown();

const lines = useMemo(() => {
if (!project) return [];
Expand Down Expand Up @@ -85,18 +87,22 @@ export function GridOverlay() {
<div className="absolute inset-0 pointer-events-none" style={{ width: totalWidth, minHeight: '100vh' }}>
{lines
.filter((line) => line.strength !== 'sub')
.map((line, i) => (
<div
key={i}
className="absolute top-0 bottom-0"
style={{
left: line.x,
width: line.strength === 'bar' ? 1 : 0,
backgroundColor: line.strength === 'bar' ? colors.bar : undefined,
borderLeft: line.strength === 'beat' ? `1px dashed ${colors.beat}` : undefined,
}}
/>
))}
.map((line, i) => {
const color = line.strength === 'bar' ? colors.bar : colors.beat;
return (
<div
key={i}
className="absolute top-0 bottom-0"
data-testid={`grid-line-${line.strength}`}
style={{
left: line.x,
...(isMetaDown
? { borderLeft: `1px dashed ${color}` }
: { width: 1, backgroundColor: color }),
}}
/>
);
})}
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/timeline/Playhead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function Playhead() {
left: transportX,
minHeight: '100vh',
backgroundColor: '#ffffff',
boxShadow: '0 0 3px rgba(0, 0, 0, 0.35), 0 0 8px rgba(0, 0, 0, 0.15)',
}}
/>
)}
Expand Down Expand Up @@ -69,6 +70,7 @@ function SelectedTrackCursor({ trackId, x, blink }: { trackId: string; x: number
height: laneEl.offsetHeight,
animation: blink ? 'playhead-blink-line 1.2s ease-in-out infinite' : undefined,
backgroundColor: blink ? undefined : '#ffffff',
boxShadow: '0 0 3px rgba(0, 0, 0, 0.35), 0 0 8px rgba(0, 0, 0, 0.15)',
}}
/>
);
Expand Down
30 changes: 30 additions & 0 deletions src/hooks/useMetaKeyDown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useState, useEffect } from 'react';

/**
* Tracks whether the Meta (Command on Mac) key is currently held down.
* Resets on window blur to avoid "stuck" state when Cmd-tabbing away.
*/
export function useMetaKeyDown(): boolean {
const [metaDown, setMetaDown] = useState(false);

useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Meta') setMetaDown(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Meta') setMetaDown(false);
};
const onBlur = () => setMetaDown(false);

window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
};
}, []);

return metaDown;
}
Loading
Loading