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:
@@ -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