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,
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user