From 92444fb02314905168046b2c63d443c91e91f476 Mon Sep 17 00:00:00 2001 From: JSC Date: Wed, 3 Sep 2025 21:35:42 +0200 Subject: [PATCH] feat: enhance time snapping and interval calculation for improved sound placement in Sequencer --- src/components/sequencer/SequencerCanvas.tsx | 118 +++---------------- src/pages/SequencerPage.tsx | 63 ++++++++-- 2 files changed, 68 insertions(+), 113 deletions(-) diff --git a/src/components/sequencer/SequencerCanvas.tsx b/src/components/sequencer/SequencerCanvas.tsx index daebda3..719c092 100644 --- a/src/components/sequencer/SequencerCanvas.tsx +++ b/src/components/sequencer/SequencerCanvas.tsx @@ -14,6 +14,7 @@ interface SequencerCanvasProps { draggedItem?: any // Current dragged item from parent dragOverInfo?: {trackId: string, x: number} | null // Drag over position info onRemoveSound: (soundId: string, trackId: string) => void + timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number} } interface TrackRowProps { @@ -25,6 +26,7 @@ interface TrackRowProps { draggedItem?: any // Current dragged item dragOverInfo?: {trackId: string, x: number} | null // Drag over position info onRemoveSound: (soundId: string, trackId: string) => void + timeIntervals: {minorIntervals: number[], majorIntervals: number[], minorInterval: number, majorInterval: number} } interface PlacedSoundItemProps { @@ -32,9 +34,10 @@ interface PlacedSoundItemProps { zoom: number trackId: string onRemove: (soundId: string) => void + minorInterval: number } -function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProps) { +function PlacedSoundItem({ sound, zoom, trackId, onRemove, minorInterval }: PlacedSoundItemProps) { const { attributes, listeners, @@ -54,18 +57,11 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, } : undefined - // Helper function to snap time to 100ms intervals - const snapToGrid = (timeInSeconds: number): number => { - const snapIntervalMs = 100 // 100ms snap interval - const timeInMs = timeInSeconds * 1000 - const snappedMs = Math.round(timeInMs / snapIntervalMs) * snapIntervalMs - return snappedMs / 1000 // Convert back to seconds - } - const width = (sound.duration / 1000) * zoom // Convert ms to seconds for zoom calculation - // Ensure placed sounds are positioned at snapped locations - const snappedStartTime = snapToGrid(sound.startTime / 1000) - const left = snappedStartTime * zoom + // Ensure placed sounds are positioned at snapped locations using current minor interval + const startTimeSeconds = sound.startTime / 1000 + const snappedStartTime = Math.round(startTimeSeconds / minorInterval) * minorInterval + const left = Math.max(0, snappedStartTime) * zoom const formatTime = (seconds: number): string => { const mins = Math.floor(seconds / 60) @@ -119,7 +115,7 @@ function PlacedSoundItem({ sound, zoom, trackId, onRemove }: PlacedSoundItemProp ) } -function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound }: TrackRowProps) { +function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, dragOverInfo, onRemoveSound, timeIntervals }: TrackRowProps) { const playheadPosition = (currentTime / 1000) * zoom // Convert ms to seconds for zoom calculation const { isOver, setNodeRef: setDropRef } = useDroppable({ @@ -134,51 +130,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, onRemoveSound(soundId, track.id) } - // Calculate logical time intervals based on zoom level (same as main component) - const getTimeIntervals = (zoom: number, duration: number) => { - const durationSeconds = duration / 1000 - const minorIntervals: number[] = [] - const majorIntervals: number[] = [] - - // Define logical interval progressions - const intervalSets = [ - { minor: 1, major: 5 }, // 1s minor, 5s major (very zoomed in) - { minor: 5, major: 30 }, // 5s minor, 30s major - { minor: 10, major: 60 }, // 10s minor, 1min major - { minor: 30, major: 300 }, // 30s minor, 5min major - { minor: 60, major: 600 }, // 1min minor, 10min major - { minor: 300, major: 1800 }, // 5min minor, 30min major - { minor: 600, major: 3600 } // 10min minor, 1h major - ] - - // Find appropriate interval set based on zoom level - // We want major intervals to be roughly 100-200px apart - const targetMajorSpacing = 150 - - let selectedIntervals = intervalSets[intervalSets.length - 1] // fallback to largest - for (const intervals of intervalSets) { - if (intervals.major * zoom >= targetMajorSpacing) { - selectedIntervals = intervals - break - } - } - - // Generate minor intervals (every interval) - for (let i = 0; i * selectedIntervals.minor <= durationSeconds; i++) { - const time = i * selectedIntervals.minor - minorIntervals.push(time) - } - - // Generate major intervals (at major boundaries) - for (let i = 0; i * selectedIntervals.major <= durationSeconds; i++) { - const time = i * selectedIntervals.major - majorIntervals.push(time) - } - - return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major } - } - - const { minorIntervals, majorIntervals } = getTimeIntervals(zoom, duration) + const { minorIntervals, majorIntervals } = timeIntervals return (
@@ -266,6 +218,7 @@ function TrackRow({ track, duration, zoom, isPlaying, currentTime, draggedItem, zoom={zoom} trackId={track.id} onRemove={handleRemoveSound} + minorInterval={timeIntervals.minorInterval} /> ))}
@@ -283,6 +236,7 @@ export const SequencerCanvas = forwardRef( draggedItem, dragOverInfo, onRemoveSound, + timeIntervals, }, ref) => { const totalWidth = (duration / 1000) * zoom // Convert ms to seconds for zoom calculation const timelineRef = useRef(null) @@ -295,52 +249,7 @@ export const SequencerCanvas = forwardRef( }, }) - // Calculate logical time intervals based on zoom level - const getTimeIntervals = (zoom: number, duration: number) => { - const durationSeconds = duration / 1000 - const minorIntervals: number[] = [] - const majorIntervals: number[] = [] - - // Define logical interval progressions - const intervalSets = [ - { minor: 0.1, major: 1 }, // 0.1s minor, 1s major (mega zoomed in) - { minor: 1, major: 5 }, // 1s minor, 5s major (very zoomed in) - { minor: 5, major: 30 }, // 5s minor, 30s major - { minor: 10, major: 60 }, // 10s minor, 1min major - { minor: 30, major: 300 }, // 30s minor, 5min major - { minor: 60, major: 600 }, // 1min minor, 10min major - { minor: 300, major: 1800 }, // 5min minor, 30min major - { minor: 600, major: 3600 } // 10min minor, 1h major - ] - - // Find appropriate interval set based on zoom level - // We want major intervals to be roughly 100-200px apart - const targetMajorSpacing = 150 - - let selectedIntervals = intervalSets[intervalSets.length - 1] // fallback to largest - for (const intervals of intervalSets) { - if (intervals.major * zoom >= targetMajorSpacing) { - selectedIntervals = intervals - break - } - } - - // Generate minor intervals (every interval) - for (let i = 0; i * selectedIntervals.minor <= durationSeconds; i++) { - const time = i * selectedIntervals.minor - minorIntervals.push(time) - } - - // Generate major intervals (at major boundaries) - for (let i = 0; i * selectedIntervals.major <= durationSeconds; i++) { - const time = i * selectedIntervals.major - majorIntervals.push(time) - } - - return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major } - } - - const { minorIntervals, majorIntervals } = getTimeIntervals(zoom, duration) + const { minorIntervals, majorIntervals } = timeIntervals const handleTracksScroll = (e: React.UIEvent) => { // Sync timeline horizontal scroll with tracks @@ -430,6 +339,7 @@ export const SequencerCanvas = forwardRef( draggedItem={draggedItem} dragOverInfo={dragOverInfo} onRemoveSound={onRemoveSound} + timeIntervals={timeIntervals} /> ))} diff --git a/src/pages/SequencerPage.tsx b/src/pages/SequencerPage.tsx index 931535f..81d8b29 100644 --- a/src/pages/SequencerPage.tsx +++ b/src/pages/SequencerPage.tsx @@ -79,14 +79,58 @@ export function SequencerPage() { } }, []) - // Helper function to snap time to 100ms intervals with improved precision - const snapToGrid = useCallback((timeInSeconds: number): number => { - const snapIntervalMs = 100 // 100ms snap interval - const timeInMs = Math.max(0, timeInSeconds * 1000) // Ensure non-negative - const snappedMs = Math.round(timeInMs / snapIntervalMs) * snapIntervalMs - return Math.max(0, snappedMs / 1000) // Convert back to seconds, ensure non-negative + // Calculate logical time intervals based on zoom level (shared with SequencerCanvas) + const getTimeIntervals = useCallback((zoom: number, duration: number) => { + const durationSeconds = duration / 1000 + const minorIntervals: number[] = [] + const majorIntervals: number[] = [] + + // Define logical interval progressions + const intervalSets = [ + { minor: 0.1, major: 1 }, // 0.1s minor, 1s major (mega zoomed in) + { minor: 1, major: 5 }, // 1s minor, 5s major (very zoomed in) + { minor: 5, major: 30 }, // 5s minor, 30s major + { minor: 10, major: 60 }, // 10s minor, 1min major + { minor: 30, major: 300 }, // 30s minor, 5min major + { minor: 60, major: 600 }, // 1min minor, 10min major + { minor: 300, major: 1800 }, // 5min minor, 30min major + { minor: 600, major: 3600 } // 10min minor, 1h major + ] + + // Find appropriate interval set based on zoom level + // We want major intervals to be roughly 100-200px apart + const targetMajorSpacing = 150 + + let selectedIntervals = intervalSets[intervalSets.length - 1] // fallback to largest + for (const intervals of intervalSets) { + if (intervals.major * zoom >= targetMajorSpacing) { + selectedIntervals = intervals + break + } + } + + // Generate minor intervals (every interval) + for (let i = 0; i * selectedIntervals.minor <= durationSeconds; i++) { + const time = i * selectedIntervals.minor + minorIntervals.push(time) + } + + // Generate major intervals (at major boundaries) + for (let i = 0; i * selectedIntervals.major <= durationSeconds; i++) { + const time = i * selectedIntervals.major + majorIntervals.push(time) + } + + return { minorIntervals, majorIntervals, minorInterval: selectedIntervals.minor, majorInterval: selectedIntervals.major } }, []) + // Helper function to snap time to the current minor interval + const snapToGrid = useCallback((timeInSeconds: number, zoom: number, duration: number): number => { + const { minorInterval } = getTimeIntervals(zoom, duration) + const snappedTime = Math.round(timeInSeconds / minorInterval) * minorInterval + return Math.max(0, snappedTime) // Ensure non-negative + }, [getTimeIntervals]) + // Update drag over info based on current mouse position and over target useEffect(() => { if (draggedItem && currentMousePos && (draggedItem.type === 'sound' || draggedItem.type === 'placed-sound')) { @@ -102,9 +146,9 @@ export function SequencerPage() { currentMousePos.y <= rect.bottom ) { const rawX = currentMousePos.x - rect.left - // Apply snapping to the drag over position for consistency + // Apply adaptive snapping to the drag over position const rawTimeSeconds = rawX / state.zoom - const snappedTimeSeconds = snapToGrid(rawTimeSeconds) + const snappedTimeSeconds = snapToGrid(rawTimeSeconds, state.zoom, state.duration) const snappedX = snappedTimeSeconds * state.zoom setDragOverInfo({ trackId: track.id, x: Math.max(0, snappedX) }) return @@ -116,7 +160,7 @@ export function SequencerPage() { } else { setDragOverInfo(null) } - }, [draggedItem, currentMousePos, state.tracks, state.zoom, snapToGrid]) + }, [draggedItem, currentMousePos, state.tracks, state.zoom, state.duration, snapToGrid]) const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event @@ -380,6 +424,7 @@ export function SequencerPage() { draggedItem={draggedItem} dragOverInfo={dragOverInfo} onRemoveSound={handleRemoveSound} + timeIntervals={getTimeIntervals(state.zoom, state.duration)} />