feat: add loop playback functionality

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 <noreply@anthropic.com>
This commit is contained in:
2025-11-20 07:31:53 +01:00
parent aba26126cc
commit a47bf09a32
3 changed files with 181 additions and 2 deletions

View File

@@ -294,6 +294,12 @@ export function AudioEditor() {
stop, stop,
seek, seek,
togglePlayPause, togglePlayPause,
loopEnabled,
loopStart,
loopEnd,
toggleLoop,
setLoopPoints,
setLoopFromSelection,
} = useMultiTrackPlayer(tracks, masterVolume, handleAutomationRecording); } = useMultiTrackPlayer(tracks, masterVolume, handleAutomationRecording);
// Reset latch triggered state when playback stops // Reset latch triggered state when playback stops
@@ -1972,6 +1978,11 @@ export function AudioEditor() {
onPunchOutTimeChange={setPunchOutTime} onPunchOutTimeChange={setPunchOutTime}
overdubEnabled={overdubEnabled} overdubEnabled={overdubEnabled}
onOverdubEnabledChange={setOverdubEnabled} onOverdubEnabledChange={setOverdubEnabled}
loopEnabled={loopEnabled}
loopStart={loopStart}
loopEnd={loopEnd}
onToggleLoop={toggleLoop}
onSetLoopPoints={setLoopPoints}
/> />
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import * as React from 'react'; 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 { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils/cn'; import { cn } from '@/lib/utils/cn';
@@ -31,6 +31,11 @@ export interface PlaybackControlsProps {
onPunchOutTimeChange?: (time: number) => void; onPunchOutTimeChange?: (time: number) => void;
overdubEnabled?: boolean; overdubEnabled?: boolean;
onOverdubEnabledChange?: (enabled: boolean) => void; onOverdubEnabledChange?: (enabled: boolean) => void;
loopEnabled?: boolean;
loopStart?: number;
loopEnd?: number;
onToggleLoop?: () => void;
onSetLoopPoints?: (start: number, end: number) => void;
} }
export function PlaybackControls({ export function PlaybackControls({
@@ -59,6 +64,11 @@ export function PlaybackControls({
onPunchOutTimeChange, onPunchOutTimeChange,
overdubEnabled = false, overdubEnabled = false,
onOverdubEnabledChange, onOverdubEnabledChange,
loopEnabled = false,
loopStart = 0,
loopEnd = 0,
onToggleLoop,
onSetLoopPoints,
}: PlaybackControlsProps) { }: PlaybackControlsProps) {
const handlePlayPause = () => { const handlePlayPause = () => {
if (isPlaying) { if (isPlaying) {
@@ -249,8 +259,80 @@ export function PlaybackControls({
</div> </div>
</> </>
)} )}
{/* Loop Toggle */}
{onToggleLoop && (
<div className="flex items-center gap-1 border-l border-border pl-2 ml-1">
<Button
variant="ghost"
size="icon-sm"
onClick={onToggleLoop}
title="Toggle Loop Playback"
className={cn(
loopEnabled && 'bg-primary/20 hover:bg-primary/30'
)}
>
<Repeat className={cn('h-3.5 w-3.5', loopEnabled && 'text-primary')} />
</Button>
</div>
)}
</div> </div>
</div> </div>
{/* Loop Points - Show when enabled */}
{loopEnabled && onSetLoopPoints && (
<div className="flex items-center gap-3 text-xs bg-muted/50 rounded px-3 py-2">
<div className="flex items-center gap-2 flex-1">
<label className="text-muted-foreground flex items-center gap-1 flex-shrink-0">
<AlignVerticalJustifyStart className="h-3 w-3" />
Loop Start
</label>
<input
type="number"
min={0}
max={loopEnd || duration}
step={0.1}
value={loopStart.toFixed(2)}
onChange={(e) => onSetLoopPoints(parseFloat(e.target.value), loopEnd)}
className="flex-1 px-2 py-1 bg-background border border-border rounded text-xs font-mono"
/>
<Button
variant="ghost"
size="sm"
onClick={() => onSetLoopPoints(currentTime, loopEnd)}
title="Set loop start to current time"
className="h-6 px-2 text-xs"
>
Set
</Button>
</div>
<div className="flex items-center gap-2 flex-1">
<label className="text-muted-foreground flex items-center gap-1 flex-shrink-0">
<AlignVerticalJustifyEnd className="h-3 w-3" />
Loop End
</label>
<input
type="number"
min={loopStart}
max={duration}
step={0.1}
value={loopEnd.toFixed(2)}
onChange={(e) => onSetLoopPoints(loopStart, parseFloat(e.target.value))}
className="flex-1 px-2 py-1 bg-background border border-border rounded text-xs font-mono"
/>
<Button
variant="ghost"
size="sm"
onClick={() => onSetLoopPoints(loopStart, currentTime)}
title="Set loop end to current time"
className="h-6 px-2 text-xs"
>
Set
</Button>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -9,6 +9,9 @@ export interface MultiTrackPlayerState {
isPlaying: boolean; isPlaying: boolean;
currentTime: number; currentTime: number;
duration: number; duration: number;
loopEnabled: boolean;
loopStart: number;
loopEnd: number;
} }
export interface TrackLevel { export interface TrackLevel {
@@ -32,6 +35,9 @@ export function useMultiTrackPlayer(
const [masterPeakLevel, setMasterPeakLevel] = useState(0); const [masterPeakLevel, setMasterPeakLevel] = useState(0);
const [masterRmsLevel, setMasterRmsLevel] = useState(0); const [masterRmsLevel, setMasterRmsLevel] = useState(0);
const [masterIsClipping, setMasterIsClipping] = useState(false); const [masterIsClipping, setMasterIsClipping] = useState(false);
const [loopEnabled, setLoopEnabled] = useState(false);
const [loopStart, setLoopStart] = useState(0);
const [loopEnd, setLoopEnd] = useState(0);
const audioContextRef = useRef<AudioContext | null>(null); const audioContextRef = useRef<AudioContext | null>(null);
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]); const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
@@ -51,12 +57,22 @@ export function useMultiTrackPlayer(
const tracksRef = useRef<Track[]>(tracks); // Always keep latest tracks const tracksRef = useRef<Track[]>(tracks); // Always keep latest tracks
const lastRecordedValuesRef = useRef<Map<string, number>>(new Map()); // Track last recorded values to detect changes const lastRecordedValuesRef = useRef<Map<string, number>>(new Map()); // Track last recorded values to detect changes
const onRecordAutomationRef = useRef<AutomationRecordingCallback | undefined>(onRecordAutomation); const onRecordAutomationRef = useRef<AutomationRecordingCallback | undefined>(onRecordAutomation);
const loopEnabledRef = useRef<boolean>(false);
const loopStartRef = useRef<number>(0);
const loopEndRef = useRef<number>(0);
// Keep tracksRef in sync with tracks prop // Keep tracksRef in sync with tracks prop
useEffect(() => { useEffect(() => {
tracksRef.current = tracks; tracksRef.current = tracks;
}, [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 // Keep onRecordAutomationRef in sync
useEffect(() => { useEffect(() => {
onRecordAutomationRef.current = onRecordAutomation; onRecordAutomationRef.current = onRecordAutomation;
@@ -71,7 +87,11 @@ export function useMultiTrackPlayer(
} }
} }
setDuration(maxDuration); 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 // Convert linear amplitude to dB scale normalized to 0-1 range
const linearToDbScale = (linear: number): number => { const linearToDbScale = (linear: number): number => {
@@ -296,6 +316,50 @@ export function useMultiTrackPlayer(
const elapsed = audioContextRef.current.currentTime - startTimeRef.current; const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
const newTime = pausedAtRef.current + elapsed; 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) { if (newTime >= duration) {
setIsPlaying(false); setIsPlaying(false);
isMonitoringLevelsRef.current = false; isMonitoringLevelsRef.current = false;
@@ -822,6 +886,22 @@ export function useMultiTrackPlayer(
setMasterIsClipping(false); 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 { return {
isPlaying, isPlaying,
currentTime, currentTime,
@@ -837,5 +917,11 @@ export function useMultiTrackPlayer(
stop, stop,
seek, seek,
togglePlayPause, togglePlayPause,
loopEnabled,
loopStart,
loopEnd,
toggleLoop,
setLoopPoints,
setLoopFromSelection,
}; };
} }