From a47bf09a32e3a3af339b6ed5b0bfa29640b9ca6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Thu, 20 Nov 2025 07:31:53 +0100 Subject: [PATCH] feat: add loop playback functionality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added complete loop functionality with UI controls: - Loop state management in useMultiTrackPlayer (loopEnabled, loopStart, loopEnd) - Automatic restart from loop start when reaching loop end during playback - Loop toggle button in PlaybackControls with Repeat icon - Loop points UI showing when loop is enabled (similar to punch in/out) - Manual loop point adjustment with number inputs - Quick set buttons to set loop points to current time - Wired loop functionality through AudioEditor component 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- components/editor/AudioEditor.tsx | 11 ++++ components/editor/PlaybackControls.tsx | 84 +++++++++++++++++++++++- lib/hooks/useMultiTrackPlayer.ts | 88 +++++++++++++++++++++++++- 3 files changed, 181 insertions(+), 2 deletions(-) diff --git a/components/editor/AudioEditor.tsx b/components/editor/AudioEditor.tsx index c53e5b8..8a5c4c2 100644 --- a/components/editor/AudioEditor.tsx +++ b/components/editor/AudioEditor.tsx @@ -294,6 +294,12 @@ export function AudioEditor() { stop, seek, togglePlayPause, + loopEnabled, + loopStart, + loopEnd, + toggleLoop, + setLoopPoints, + setLoopFromSelection, } = useMultiTrackPlayer(tracks, masterVolume, handleAutomationRecording); // Reset latch triggered state when playback stops @@ -1972,6 +1978,11 @@ export function AudioEditor() { onPunchOutTimeChange={setPunchOutTime} overdubEnabled={overdubEnabled} onOverdubEnabledChange={setOverdubEnabled} + loopEnabled={loopEnabled} + loopStart={loopStart} + loopEnd={loopEnd} + onToggleLoop={toggleLoop} + onSetLoopPoints={setLoopPoints} /> diff --git a/components/editor/PlaybackControls.tsx b/components/editor/PlaybackControls.tsx index 8e2743f..ad8c5ca 100644 --- a/components/editor/PlaybackControls.tsx +++ b/components/editor/PlaybackControls.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { Play, Pause, Square, SkipBack, Circle, AlignVerticalJustifyStart, AlignVerticalJustifyEnd, Layers } from 'lucide-react'; +import { Play, Pause, Square, SkipBack, Circle, AlignVerticalJustifyStart, AlignVerticalJustifyEnd, Layers, Repeat } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { cn } from '@/lib/utils/cn'; @@ -31,6 +31,11 @@ export interface PlaybackControlsProps { onPunchOutTimeChange?: (time: number) => void; overdubEnabled?: boolean; onOverdubEnabledChange?: (enabled: boolean) => void; + loopEnabled?: boolean; + loopStart?: number; + loopEnd?: number; + onToggleLoop?: () => void; + onSetLoopPoints?: (start: number, end: number) => void; } export function PlaybackControls({ @@ -59,6 +64,11 @@ export function PlaybackControls({ onPunchOutTimeChange, overdubEnabled = false, onOverdubEnabledChange, + loopEnabled = false, + loopStart = 0, + loopEnd = 0, + onToggleLoop, + onSetLoopPoints, }: PlaybackControlsProps) { const handlePlayPause = () => { if (isPlaying) { @@ -249,8 +259,80 @@ export function PlaybackControls({ )} + + {/* Loop Toggle */} + {onToggleLoop && ( +
+ +
+ )} + + {/* Loop Points - Show when enabled */} + {loopEnabled && onSetLoopPoints && ( +
+
+ + onSetLoopPoints(parseFloat(e.target.value), loopEnd)} + className="flex-1 px-2 py-1 bg-background border border-border rounded text-xs font-mono" + /> + +
+ +
+ + onSetLoopPoints(loopStart, parseFloat(e.target.value))} + className="flex-1 px-2 py-1 bg-background border border-border rounded text-xs font-mono" + /> + +
+
+ )} ); } diff --git a/lib/hooks/useMultiTrackPlayer.ts b/lib/hooks/useMultiTrackPlayer.ts index e9f0c70..edc2e64 100644 --- a/lib/hooks/useMultiTrackPlayer.ts +++ b/lib/hooks/useMultiTrackPlayer.ts @@ -9,6 +9,9 @@ export interface MultiTrackPlayerState { isPlaying: boolean; currentTime: number; duration: number; + loopEnabled: boolean; + loopStart: number; + loopEnd: number; } export interface TrackLevel { @@ -32,6 +35,9 @@ export function useMultiTrackPlayer( const [masterPeakLevel, setMasterPeakLevel] = useState(0); const [masterRmsLevel, setMasterRmsLevel] = useState(0); const [masterIsClipping, setMasterIsClipping] = useState(false); + const [loopEnabled, setLoopEnabled] = useState(false); + const [loopStart, setLoopStart] = useState(0); + const [loopEnd, setLoopEnd] = useState(0); const audioContextRef = useRef(null); const sourceNodesRef = useRef([]); @@ -51,12 +57,22 @@ export function useMultiTrackPlayer( const tracksRef = useRef(tracks); // Always keep latest tracks const lastRecordedValuesRef = useRef>(new Map()); // Track last recorded values to detect changes const onRecordAutomationRef = useRef(onRecordAutomation); + const loopEnabledRef = useRef(false); + const loopStartRef = useRef(0); + const loopEndRef = useRef(0); // Keep tracksRef in sync with tracks prop useEffect(() => { tracksRef.current = tracks; }, [tracks]); + // Keep loop refs in sync with state + useEffect(() => { + loopEnabledRef.current = loopEnabled; + loopStartRef.current = loopStart; + loopEndRef.current = loopEnd; + }, [loopEnabled, loopStart, loopEnd]); + // Keep onRecordAutomationRef in sync useEffect(() => { onRecordAutomationRef.current = onRecordAutomation; @@ -71,7 +87,11 @@ export function useMultiTrackPlayer( } } setDuration(maxDuration); - }, [tracks]); + // Initialize loop end to duration when duration changes + if (loopEnd === 0 || loopEnd > maxDuration) { + setLoopEnd(maxDuration); + } + }, [tracks, loopEnd]); // Convert linear amplitude to dB scale normalized to 0-1 range const linearToDbScale = (linear: number): number => { @@ -296,6 +316,50 @@ export function useMultiTrackPlayer( const elapsed = audioContextRef.current.currentTime - startTimeRef.current; const newTime = pausedAtRef.current + elapsed; + // Check if loop is enabled and we've reached the loop end + if (loopEnabledRef.current && loopEndRef.current > loopStartRef.current && newTime >= loopEndRef.current) { + // Loop back to start + pausedAtRef.current = loopStartRef.current; + startTimeRef.current = audioContextRef.current.currentTime; + setCurrentTime(loopStartRef.current); + + // Restart all sources from loop start + sourceNodesRef.current.forEach((node, index) => { + try { + node.stop(); + node.disconnect(); + } catch (e) { + // Ignore errors from already stopped nodes + } + }); + + // Re-trigger play from loop start + const tracks = tracksRef.current; + const audioContext = audioContextRef.current; + + // Clear old sources + sourceNodesRef.current = []; + + // Create new sources starting from loop start + for (const track of tracks) { + if (!track.audioBuffer) continue; + + const source = audioContext.createBufferSource(); + source.buffer = track.audioBuffer; + + // Connect to existing nodes (gain, pan, effects are still connected) + const trackIndex = tracks.indexOf(track); + source.connect(analyserNodesRef.current[trackIndex]); + + // Start from loop start position + source.start(0, loopStartRef.current); + sourceNodesRef.current.push(source); + } + + animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition); + return; + } + if (newTime >= duration) { setIsPlaying(false); isMonitoringLevelsRef.current = false; @@ -822,6 +886,22 @@ export function useMultiTrackPlayer( setMasterIsClipping(false); }, []); + const toggleLoop = useCallback(() => { + setLoopEnabled(prev => !prev); + }, []); + + const setLoopPoints = useCallback((start: number, end: number) => { + setLoopStart(Math.max(0, start)); + setLoopEnd(Math.min(duration, Math.max(start, end))); + }, [duration]); + + const setLoopFromSelection = useCallback((selectionStart: number, selectionEnd: number) => { + if (selectionStart < selectionEnd) { + setLoopPoints(selectionStart, selectionEnd); + setLoopEnabled(true); + } + }, [setLoopPoints]); + return { isPlaying, currentTime, @@ -837,5 +917,11 @@ export function useMultiTrackPlayer( stop, seek, togglePlayPause, + loopEnabled, + loopStart, + loopEnd, + toggleLoop, + setLoopPoints, + setLoopFromSelection, }; }