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:
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AudioContext | null>(null);
|
||||
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
|
||||
@@ -51,12 +57,22 @@ export function useMultiTrackPlayer(
|
||||
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 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
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user