Files
audio-ui/lib/hooks/useMultiTrackPlayer.ts
Sebastian Krüger fa69ac649c fix: prevent playhead marker from continuing after pause
Fixed race condition where animation frames could continue after pause() by
adding an isPlayingRef that's checked at the start of updatePlaybackPosition.
This ensures any queued animation frames will exit early if pause was called,
preventing the playhead from continuing to move when audio has stopped.

This fixes the issue where the playhead marker would occasionally keep moving
after hitting spacebar to pause, even though no audio was playing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 12:53:13 +01:00

956 lines
33 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react';
import { getAudioContext } from '@/lib/audio/context';
import type { Track } from '@/types/track';
import { getTrackGain } from '@/lib/audio/track-utils';
import { applyEffectChain, updateEffectParameters, toggleEffectBypass, type EffectNodeInfo } from '@/lib/audio/effects/processor';
import { evaluateAutomationLinear } from '@/lib/audio/automation-utils';
export interface MultiTrackPlayerState {
isPlaying: boolean;
currentTime: number;
duration: number;
loopEnabled: boolean;
loopStart: number;
loopEnd: number;
playbackRate: number;
}
export interface TrackLevel {
trackId: string;
level: number;
}
export interface AutomationRecordingCallback {
(trackId: string, laneId: string, currentTime: number, value: number): void;
}
export function useMultiTrackPlayer(
tracks: Track[],
masterVolume: number = 1,
onRecordAutomation?: AutomationRecordingCallback
) {
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [trackLevels, setTrackLevels] = useState<Record<string, number>>({});
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 [playbackRate, setPlaybackRate] = useState(1.0);
const audioContextRef = useRef<AudioContext | null>(null);
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
const gainNodesRef = useRef<GainNode[]>([]);
const panNodesRef = useRef<StereoPannerNode[]>([]);
const analyserNodesRef = useRef<AnalyserNode[]>([]);
const effectNodesRef = useRef<EffectNodeInfo[][]>([]); // Effect nodes per track
const masterGainNodeRef = useRef<GainNode | null>(null);
const masterAnalyserRef = useRef<AnalyserNode | null>(null);
const masterLevelMonitorFrameRef = useRef<number | null>(null);
const startTimeRef = useRef<number>(0);
const pausedAtRef = useRef<number>(0);
const animationFrameRef = useRef<number | null>(null);
const levelMonitorFrameRef = useRef<number | null>(null);
const automationFrameRef = useRef<number | null>(null);
const isMonitoringLevelsRef = useRef<boolean>(false);
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);
const playbackRateRef = useRef<number>(1.0);
const isPlayingRef = useRef<boolean>(false);
// 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 playbackRate ref in sync with state
useEffect(() => {
playbackRateRef.current = playbackRate;
}, [playbackRate]);
// Keep onRecordAutomationRef in sync
useEffect(() => {
onRecordAutomationRef.current = onRecordAutomation;
}, [onRecordAutomation]);
// Calculate total duration from all tracks
useEffect(() => {
let maxDuration = 0;
for (const track of tracks) {
if (track.audioBuffer) {
maxDuration = Math.max(maxDuration, track.audioBuffer.duration);
}
}
setDuration(maxDuration);
// 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 => {
if (linear === 0) return 0;
// Convert to dB (20 * log10(linear))
const db = 20 * Math.log10(linear);
// Normalize -60dB to 0dB range to 0-1
// -60dB or lower = 0%, 0dB = 100%
const minDb = -60;
const maxDb = 0;
const normalized = (db - minDb) / (maxDb - minDb);
// Clamp to 0-1 range
return Math.max(0, Math.min(1, normalized));
};
// Monitor playback levels for all tracks
const monitorPlaybackLevels = useCallback(() => {
if (!isMonitoringLevelsRef.current || analyserNodesRef.current.length === 0) return;
const levels: Record<string, number> = {};
analyserNodesRef.current.forEach((analyser, index) => {
const track = tracksRef.current[index];
if (!track) return;
const dataArray = new Float32Array(analyser.fftSize);
analyser.getFloatTimeDomainData(dataArray);
// Calculate peak level using float data (-1 to +1 range)
let peak = 0;
for (let i = 0; i < dataArray.length; i++) {
const abs = Math.abs(dataArray[i]);
if (abs > peak) {
peak = abs;
}
}
// Store raw linear peak (will be converted to dB in the fader component)
levels[track.id] = peak;
});
setTrackLevels(levels);
levelMonitorFrameRef.current = requestAnimationFrame(monitorPlaybackLevels);
}, []);
// Monitor master output levels (peak and RMS)
const monitorMasterLevels = useCallback(() => {
if (!masterAnalyserRef.current) {
return;
}
const analyser = masterAnalyserRef.current;
const bufferLength = analyser.fftSize;
const dataArray = new Float32Array(bufferLength);
analyser.getFloatTimeDomainData(dataArray);
// Calculate peak level (max absolute value)
let peak = 0;
for (let i = 0; i < bufferLength; i++) {
const abs = Math.abs(dataArray[i]);
if (abs > peak) {
peak = abs;
}
}
// Calculate RMS level (root mean square)
let sumSquares = 0;
for (let i = 0; i < bufferLength; i++) {
sumSquares += dataArray[i] * dataArray[i];
}
const rms = Math.sqrt(sumSquares / bufferLength);
// Detect clipping (signal >= 1.0)
const isClipping = peak >= 1.0;
setMasterPeakLevel(peak);
setMasterRmsLevel(rms);
if (isClipping) {
setMasterIsClipping(true);
}
masterLevelMonitorFrameRef.current = requestAnimationFrame(monitorMasterLevels);
}, []);
// Apply automation values during playback
const applyAutomation = useCallback(() => {
if (!audioContextRef.current) return;
const currentTime = pausedAtRef.current + (audioContextRef.current.currentTime - startTimeRef.current);
tracksRef.current.forEach((track, index) => {
// Apply volume automation
const volumeLane = track.automation.lanes.find(lane => lane.parameterId === 'volume');
if (volumeLane) {
let volumeValue: number | undefined;
// In write mode, record current track volume (only if value changed)
if (volumeLane.mode === 'write' && onRecordAutomationRef.current) {
volumeValue = track.volume;
const lastValue = lastRecordedValuesRef.current.get(`${track.id}-volume`);
// Only record if value has changed
if (lastValue === undefined || Math.abs(lastValue - volumeValue) > 0.001) {
lastRecordedValuesRef.current.set(`${track.id}-volume`, volumeValue);
onRecordAutomationRef.current(track.id, volumeLane.id, currentTime, volumeValue);
}
} else if (volumeLane.points.length > 0) {
// Otherwise play back automation
volumeValue = evaluateAutomationLinear(volumeLane.points, currentTime);
}
if (volumeValue !== undefined && gainNodesRef.current[index]) {
const trackGain = getTrackGain(track, tracks);
// Apply both track gain (mute/solo) and automated volume
gainNodesRef.current[index].gain.setValueAtTime(
trackGain * volumeValue,
audioContextRef.current!.currentTime
);
}
}
// Apply pan automation
const panLane = track.automation.lanes.find(lane => lane.parameterId === 'pan');
if (panLane) {
let automatedValue: number | undefined;
// In write mode, record current track pan (only if value changed)
if (panLane.mode === 'write' && onRecordAutomationRef.current) {
automatedValue = (track.pan + 1) / 2; // Convert -1 to 1 -> 0 to 1
const lastValue = lastRecordedValuesRef.current.get(`${track.id}-pan`);
// Only record if value has changed
if (lastValue === undefined || Math.abs(lastValue - automatedValue) > 0.001) {
lastRecordedValuesRef.current.set(`${track.id}-pan`, automatedValue);
onRecordAutomationRef.current(track.id, panLane.id, currentTime, automatedValue);
}
} else if (panLane.points.length > 0) {
// Otherwise play back automation
automatedValue = evaluateAutomationLinear(panLane.points, currentTime);
}
if (automatedValue !== undefined && panNodesRef.current[index]) {
// Pan automation values are 0-1, but StereoPannerNode expects -1 to 1
const panValue = (automatedValue * 2) - 1;
panNodesRef.current[index].pan.setValueAtTime(
panValue,
audioContextRef.current!.currentTime
);
}
}
// Apply effect parameter automation
track.automation.lanes.forEach(lane => {
// Check if this is an effect parameter (format: effect.{effectId}.{parameterName})
if (lane.parameterId.startsWith('effect.')) {
const parts = lane.parameterId.split('.');
if (parts.length === 3) {
const effectId = parts[1];
const paramName = parts[2];
// Find the effect in the track's effect chain
const effectIndex = track.effectChain.effects.findIndex(e => e.id === effectId);
const effect = track.effectChain.effects[effectIndex];
if (effectIndex >= 0 && effect) {
let automatedValue: number | undefined;
// In write mode, record current effect parameter value (only if value changed)
if (lane.mode === 'write' && onRecordAutomationRef.current && effect.parameters) {
const currentValue = (effect.parameters as any)[paramName];
if (currentValue !== undefined) {
// Normalize value to 0-1 range
const range = lane.valueRange.max - lane.valueRange.min;
const normalizedValue = (currentValue - lane.valueRange.min) / range;
const lastValue = lastRecordedValuesRef.current.get(`${track.id}-effect-${effectId}-${paramName}`);
// Only record if value has changed
if (lastValue === undefined || Math.abs(lastValue - normalizedValue) > 0.001) {
lastRecordedValuesRef.current.set(`${track.id}-effect-${effectId}-${paramName}`, normalizedValue);
onRecordAutomationRef.current(track.id, lane.id, currentTime, normalizedValue);
}
}
} else if (lane.points.length > 0) {
// Otherwise play back automation
automatedValue = evaluateAutomationLinear(lane.points, currentTime);
}
// Apply the automated value to the effect
if (automatedValue !== undefined && effectNodesRef.current[index] && effectNodesRef.current[index][effectIndex]) {
const effectNodeInfo = effectNodesRef.current[index][effectIndex];
// Convert normalized 0-1 value to actual parameter range
const actualValue = lane.valueRange.min + (automatedValue * (lane.valueRange.max - lane.valueRange.min));
// Update the effect parameter
if (effect.parameters) {
const updatedParams = { ...effect.parameters, [paramName]: actualValue } as any;
updateEffectParameters(audioContextRef.current!, effectNodeInfo, {
...effect,
parameters: updatedParams
});
}
}
}
}
}
});
});
automationFrameRef.current = requestAnimationFrame(applyAutomation);
}, []);
const updatePlaybackPosition = useCallback(() => {
if (!audioContextRef.current || !isPlayingRef.current) return;
const elapsed = (audioContextRef.current.currentTime - startTimeRef.current) * playbackRateRef.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;
source.playbackRate.value = playbackRateRef.current;
// 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;
setCurrentTime(0);
pausedAtRef.current = 0;
setTrackLevels({});
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (levelMonitorFrameRef.current) {
cancelAnimationFrame(levelMonitorFrameRef.current);
levelMonitorFrameRef.current = null;
}
if (automationFrameRef.current) {
cancelAnimationFrame(automationFrameRef.current);
automationFrameRef.current = null;
}
return;
}
setCurrentTime(newTime);
animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition);
}, [duration]);
const play = useCallback(() => {
if (tracks.length === 0 || tracks.every(t => !t.audioBuffer)) return;
const audioContext = getAudioContext();
audioContextRef.current = audioContext;
// Stop any existing playback
sourceNodesRef.current.forEach(node => {
try {
node.stop();
node.disconnect();
} catch (e) {
// Ignore errors from already stopped nodes
}
});
gainNodesRef.current.forEach(node => node.disconnect());
panNodesRef.current.forEach(node => node.disconnect());
if (masterGainNodeRef.current) {
masterGainNodeRef.current.disconnect();
}
sourceNodesRef.current = [];
gainNodesRef.current = [];
panNodesRef.current = [];
analyserNodesRef.current = [];
effectNodesRef.current = [];
// Create master gain node with analyser for metering
const masterGain = audioContext.createGain();
masterGain.gain.setValueAtTime(masterVolume, audioContext.currentTime);
const masterAnalyser = audioContext.createAnalyser();
masterAnalyser.fftSize = 256;
masterAnalyser.smoothingTimeConstant = 0.8;
// Connect: masterGain -> analyser -> destination
masterGain.connect(masterAnalyser);
masterAnalyser.connect(audioContext.destination);
masterGainNodeRef.current = masterGain;
masterAnalyserRef.current = masterAnalyser;
// Create audio graph for each track
for (const track of tracks) {
if (!track.audioBuffer) continue;
const source = audioContext.createBufferSource();
source.buffer = track.audioBuffer;
const gainNode = audioContext.createGain();
const panNode = audioContext.createStereoPanner();
const analyserNode = audioContext.createAnalyser();
analyserNode.fftSize = 256;
analyserNode.smoothingTimeConstant = 0.8;
// Set gain based on track volume and solo/mute state
const trackGain = getTrackGain(track, tracks);
gainNode.gain.setValueAtTime(trackGain, audioContext.currentTime);
// Set pan
panNode.pan.setValueAtTime(track.pan, audioContext.currentTime);
// Connect: source -> analyser -> gain -> pan -> effects -> master gain -> destination
// Analyser is before gain so it shows raw audio levels independent of volume fader
source.connect(analyserNode);
analyserNode.connect(gainNode);
gainNode.connect(panNode);
// Apply effect chain
console.log('[MultiTrackPlayer] Applying effect chain for track:', track.name);
console.log('[MultiTrackPlayer] Effect chain ID:', track.effectChain.id);
console.log('[MultiTrackPlayer] Effect chain name:', track.effectChain.name);
console.log('[MultiTrackPlayer] Number of effects:', track.effectChain.effects.length);
console.log('[MultiTrackPlayer] Effects:', track.effectChain.effects);
const { outputNode, effectNodes } = applyEffectChain(audioContext, panNode, track.effectChain);
// Connect to master gain
outputNode.connect(masterGain);
console.log('[MultiTrackPlayer] Effect output connected with', effectNodes.length, 'effect nodes');
// Set playback rate
source.playbackRate.value = playbackRateRef.current;
// Start playback from current position
source.start(0, pausedAtRef.current);
// Store references
sourceNodesRef.current.push(source);
gainNodesRef.current.push(gainNode);
panNodesRef.current.push(panNode);
analyserNodesRef.current.push(analyserNode);
effectNodesRef.current.push(effectNodes);
// Handle ended event
source.onended = () => {
if (pausedAtRef.current + (audioContext.currentTime - startTimeRef.current) >= duration) {
setIsPlaying(false);
isMonitoringLevelsRef.current = false;
setCurrentTime(0);
pausedAtRef.current = 0;
setTrackLevels({});
}
};
}
startTimeRef.current = audioContext.currentTime;
isPlayingRef.current = true;
setIsPlaying(true);
updatePlaybackPosition();
// Start level monitoring
isMonitoringLevelsRef.current = true;
monitorPlaybackLevels();
monitorMasterLevels();
// Start automation
applyAutomation();
}, [tracks, duration, masterVolume, updatePlaybackPosition, monitorPlaybackLevels, monitorMasterLevels, applyAutomation]);
const pause = useCallback(() => {
if (!audioContextRef.current || !isPlaying) return;
// Stop all source nodes
sourceNodesRef.current.forEach(node => {
try {
node.stop();
node.disconnect();
} catch (e) {
// Ignore errors
}
});
// Update paused position
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
pausedAtRef.current = Math.min(pausedAtRef.current + elapsed, duration);
setCurrentTime(pausedAtRef.current);
isPlayingRef.current = false;
setIsPlaying(false);
// Stop level monitoring
isMonitoringLevelsRef.current = false;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (levelMonitorFrameRef.current) {
cancelAnimationFrame(levelMonitorFrameRef.current);
levelMonitorFrameRef.current = null;
}
if (masterLevelMonitorFrameRef.current) {
cancelAnimationFrame(masterLevelMonitorFrameRef.current);
masterLevelMonitorFrameRef.current = null;
}
if (automationFrameRef.current) {
cancelAnimationFrame(automationFrameRef.current);
automationFrameRef.current = null;
}
// Clear track levels
setTrackLevels({});
}, [isPlaying, duration]);
const stop = useCallback(() => {
pause();
pausedAtRef.current = 0;
setCurrentTime(0);
// Clear last recorded values when stopping
lastRecordedValuesRef.current.clear();
}, [pause]);
const seek = useCallback((time: number) => {
const wasPlaying = isPlaying;
if (wasPlaying) {
pause();
}
const clampedTime = Math.max(0, Math.min(time, duration));
pausedAtRef.current = clampedTime;
setCurrentTime(clampedTime);
if (wasPlaying) {
// Small delay to ensure state is updated
setTimeout(() => play(), 10);
}
}, [isPlaying, duration, pause, play]);
const togglePlayPause = useCallback(() => {
if (isPlaying) {
pause();
} else {
play();
}
}, [isPlaying, play, pause]);
// Update gain/pan when tracks change (simple updates that don't require graph rebuild)
useEffect(() => {
if (!isPlaying || !audioContextRef.current) return;
tracks.forEach((track, index) => {
if (gainNodesRef.current[index]) {
const trackGain = getTrackGain(track, tracks);
gainNodesRef.current[index].gain.setValueAtTime(
trackGain,
audioContextRef.current!.currentTime
);
}
if (panNodesRef.current[index]) {
panNodesRef.current[index].pan.setValueAtTime(
track.pan,
audioContextRef.current!.currentTime
);
}
});
}, [tracks, isPlaying]);
// Track effect chain structure to detect add/remove operations
const previousEffectStructureRef = useRef<string | null>(null);
// Detect effect chain structure changes (add/remove/reorder) and restart
useEffect(() => {
if (!isPlaying || !audioContextRef.current) return;
// Create a signature of the current effect structure (IDs and count)
const currentStructure = tracks.map(track =>
track.effectChain.effects.map(e => e.id).join(',')
).join('|');
// If structure changed (effects added/removed/reordered) while playing, restart
// Don't restart if tracks is empty (intermediate state during updates)
if (previousEffectStructureRef.current !== null &&
previousEffectStructureRef.current !== currentStructure &&
tracks.length > 0) {
console.log('[useMultiTrackPlayer] Effect chain structure changed, restarting...');
// Update the reference immediately to prevent re-triggering
previousEffectStructureRef.current = currentStructure;
// Update tracksRef with current tracks BEFORE setTimeout
tracksRef.current = tracks;
// Save current position
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
const currentPos = pausedAtRef.current + elapsed;
// Stop all source nodes
sourceNodesRef.current.forEach(node => {
try {
node.onended = null;
node.stop();
node.disconnect();
} catch (e) {
// Ignore errors
}
});
// Update position
pausedAtRef.current = currentPos;
setCurrentTime(currentPos);
setIsPlaying(false);
// Clear animation frame
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
// Restart after a brief delay
setTimeout(() => {
// Use tracksRef.current to get the latest tracks, not the stale closure
const latestTracks = tracksRef.current;
if (latestTracks.length === 0 || latestTracks.every(t => !t.audioBuffer)) return;
const audioContext = getAudioContext();
audioContextRef.current = audioContext;
// Disconnect old nodes
gainNodesRef.current.forEach(node => node.disconnect());
panNodesRef.current.forEach(node => node.disconnect());
effectNodesRef.current.forEach(trackEffects => {
trackEffects.forEach(effectNodeInfo => {
if (effectNodeInfo.node) {
try {
effectNodeInfo.node.disconnect();
} catch (e) {
// Ignore
}
}
if (effectNodeInfo.dryGain) effectNodeInfo.dryGain.disconnect();
if (effectNodeInfo.wetGain) effectNodeInfo.wetGain.disconnect();
});
});
if (masterGainNodeRef.current) {
masterGainNodeRef.current.disconnect();
}
// Reset refs
sourceNodesRef.current = [];
gainNodesRef.current = [];
panNodesRef.current = [];
analyserNodesRef.current = [];
effectNodesRef.current = [];
// Create master gain node
const masterGain = audioContext.createGain();
masterGain.gain.setValueAtTime(masterVolume, audioContext.currentTime);
masterGain.connect(audioContext.destination);
masterGainNodeRef.current = masterGain;
// Create audio graph for each track
for (const track of latestTracks) {
if (!track.audioBuffer) continue;
const source = audioContext.createBufferSource();
source.buffer = track.audioBuffer;
const gainNode = audioContext.createGain();
const panNode = audioContext.createStereoPanner();
const analyserNode = audioContext.createAnalyser();
analyserNode.fftSize = 256;
analyserNode.smoothingTimeConstant = 0.8;
// Set gain based on track volume and solo/mute state
const trackGain = getTrackGain(track, latestTracks);
gainNode.gain.setValueAtTime(trackGain, audioContext.currentTime);
// Set pan
panNode.pan.setValueAtTime(track.pan, audioContext.currentTime);
// Connect: source -> analyser -> gain -> pan -> effects -> master gain -> destination
// Analyser is before gain so it shows raw audio levels independent of volume fader
source.connect(analyserNode);
analyserNode.connect(gainNode);
gainNode.connect(panNode);
// Apply effect chain
const { outputNode, effectNodes } = applyEffectChain(audioContext, panNode, track.effectChain);
outputNode.connect(masterGain);
// Start playback from current position
source.start(0, pausedAtRef.current);
// Store references
sourceNodesRef.current.push(source);
gainNodesRef.current.push(gainNode);
panNodesRef.current.push(panNode);
analyserNodesRef.current.push(analyserNode);
effectNodesRef.current.push(effectNodes);
// Handle ended event
source.onended = () => {
if (pausedAtRef.current + (audioContext.currentTime - startTimeRef.current) >= duration) {
setIsPlaying(false);
isMonitoringLevelsRef.current = false;
setCurrentTime(0);
pausedAtRef.current = 0;
setTrackLevels({});
}
};
}
startTimeRef.current = audioContext.currentTime;
setIsPlaying(true);
// Start level monitoring
isMonitoringLevelsRef.current = true;
// Start animation frame for position updates
const updatePosition = () => {
if (!audioContextRef.current) return;
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
const newTime = pausedAtRef.current + elapsed;
if (newTime >= duration) {
setIsPlaying(false);
isMonitoringLevelsRef.current = false;
setCurrentTime(0);
pausedAtRef.current = 0;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
if (levelMonitorFrameRef.current) {
cancelAnimationFrame(levelMonitorFrameRef.current);
levelMonitorFrameRef.current = null;
}
if (masterLevelMonitorFrameRef.current) {
cancelAnimationFrame(masterLevelMonitorFrameRef.current);
masterLevelMonitorFrameRef.current = null;
}
if (automationFrameRef.current) {
cancelAnimationFrame(automationFrameRef.current);
automationFrameRef.current = null;
}
return;
}
setCurrentTime(newTime);
animationFrameRef.current = requestAnimationFrame(updatePosition);
};
updatePosition();
monitorPlaybackLevels();
monitorMasterLevels();
applyAutomation();
}, 10);
}
previousEffectStructureRef.current = currentStructure;
}, [tracks, isPlaying, duration, masterVolume, monitorPlaybackLevels, monitorMasterLevels, applyAutomation]);
// Stop playback when all tracks are deleted
useEffect(() => {
if (!isPlaying) return;
// If tracks become empty or all tracks have no audio buffer, stop playback
if (tracks.length === 0 || tracks.every(t => !t.audioBuffer)) {
console.log('[useMultiTrackPlayer] All tracks deleted, stopping playback');
stop();
}
}, [tracks, isPlaying, stop]);
// Update effect parameters and bypass state in real-time
useEffect(() => {
if (!isPlaying || !audioContextRef.current) return;
tracks.forEach((track, trackIndex) => {
const effectNodes = effectNodesRef.current[trackIndex];
if (!effectNodes) return;
// Only update if we have the same number of effects (no add/remove)
if (effectNodes.length !== track.effectChain.effects.length) return;
track.effectChain.effects.forEach((effect, effectIndex) => {
const effectNodeInfo = effectNodes[effectIndex];
if (!effectNodeInfo) return;
// Update bypass state
if (effect.enabled !== effectNodeInfo.effect.enabled) {
toggleEffectBypass(audioContextRef.current!, effectNodeInfo, effect.enabled);
effectNodeInfo.effect.enabled = effect.enabled;
}
// Update parameters (only works for certain effect types)
if (JSON.stringify(effect.parameters) !== JSON.stringify(effectNodeInfo.effect.parameters)) {
updateEffectParameters(audioContextRef.current!, effectNodeInfo, effect);
effectNodeInfo.effect.parameters = effect.parameters;
}
});
});
}, [tracks, isPlaying]);
// Update master volume when it changes
useEffect(() => {
if (!isPlaying || !audioContextRef.current || !masterGainNodeRef.current) return;
masterGainNodeRef.current.gain.setValueAtTime(
masterVolume,
audioContextRef.current.currentTime
);
}, [masterVolume, isPlaying]);
// Cleanup on unmount
useEffect(() => {
return () => {
isMonitoringLevelsRef.current = false;
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current);
}
if (levelMonitorFrameRef.current) {
cancelAnimationFrame(levelMonitorFrameRef.current);
}
if (masterLevelMonitorFrameRef.current) {
cancelAnimationFrame(masterLevelMonitorFrameRef.current);
}
if (automationFrameRef.current) {
cancelAnimationFrame(automationFrameRef.current);
}
sourceNodesRef.current.forEach(node => {
try {
node.stop();
node.disconnect();
} catch (e) {
// Ignore
}
});
gainNodesRef.current.forEach(node => node.disconnect());
panNodesRef.current.forEach(node => node.disconnect());
analyserNodesRef.current.forEach(node => node.disconnect());
if (masterGainNodeRef.current) {
masterGainNodeRef.current.disconnect();
}
};
}, []);
const resetClipIndicator = useCallback(() => {
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]);
const changePlaybackRate = useCallback((rate: number) => {
// Clamp rate between 0.25x and 2x
const clampedRate = Math.max(0.25, Math.min(2.0, rate));
setPlaybackRate(clampedRate);
// Update playback rate on all active source nodes
sourceNodesRef.current.forEach(source => {
source.playbackRate.value = clampedRate;
});
}, []);
return {
isPlaying,
currentTime,
duration,
trackLevels,
masterPeakLevel,
masterRmsLevel,
masterIsClipping,
masterAnalyser: masterAnalyserRef.current,
resetClipIndicator,
play,
pause,
stop,
seek,
togglePlayPause,
loopEnabled,
loopStart,
loopEnd,
toggleLoop,
setLoopPoints,
setLoopFromSelection,
playbackRate,
changePlaybackRate,
};
}