Added real-time multi-track audio mixing and playback: **useMultiTrackPlayer Hook:** - Real-time multi-track audio mixing with Web Audio API - Synchronized playback across all tracks - Dynamic gain control respecting solo/mute states - Per-track panning with constant power panning - Seek functionality with automatic resume - Playback position tracking with requestAnimationFrame - Automatic duration calculation from longest track - Clean resource management and cleanup **Features:** - ✅ Play/Pause/Stop controls for multi-track - ✅ Solo/Mute handling (if any track is soloed, only soloed tracks play) - ✅ Per-track volume control (0-1 range) - ✅ Per-track pan control (-1 left to +1 right) - ✅ Real-time parameter updates during playback - ✅ Seamless seek with playback state preservation - ✅ Automatic stop when reaching end of longest track **Audio Graph Architecture:** For each track: BufferSource → GainNode → StereoPannerNode → Destination The mixer applies: - Volume attenuation based on track volume setting - Solo/Mute logic (getTrackGain utility) - Constant power panning for smooth stereo positioning Next: Integrate multi-track UI into AudioEditor 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
229 lines
6.3 KiB
TypeScript
229 lines
6.3 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';
|
|
|
|
export interface MultiTrackPlayerState {
|
|
isPlaying: boolean;
|
|
currentTime: number;
|
|
duration: number;
|
|
}
|
|
|
|
export function useMultiTrackPlayer(tracks: Track[]) {
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
const audioContextRef = useRef<AudioContext | null>(null);
|
|
const sourceNodesRef = useRef<AudioBufferSourceNode[]>([]);
|
|
const gainNodesRef = useRef<GainNode[]>([]);
|
|
const panNodesRef = useRef<StereoPannerNode[]>([]);
|
|
const startTimeRef = useRef<number>(0);
|
|
const pausedAtRef = useRef<number>(0);
|
|
const animationFrameRef = useRef<number | null>(null);
|
|
|
|
// 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);
|
|
}, [tracks]);
|
|
|
|
const updatePlaybackPosition = useCallback(() => {
|
|
if (!audioContextRef.current || !isPlaying) return;
|
|
|
|
const elapsed = audioContextRef.current.currentTime - startTimeRef.current;
|
|
const newTime = pausedAtRef.current + elapsed;
|
|
|
|
if (newTime >= duration) {
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
pausedAtRef.current = 0;
|
|
return;
|
|
}
|
|
|
|
setCurrentTime(newTime);
|
|
animationFrameRef.current = requestAnimationFrame(updatePlaybackPosition);
|
|
}, [isPlaying, 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());
|
|
|
|
sourceNodesRef.current = [];
|
|
gainNodesRef.current = [];
|
|
panNodesRef.current = [];
|
|
|
|
// 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();
|
|
|
|
// 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 -> gain -> pan -> destination
|
|
source.connect(gainNode);
|
|
gainNode.connect(panNode);
|
|
panNode.connect(audioContext.destination);
|
|
|
|
// Start playback from current position
|
|
source.start(0, pausedAtRef.current);
|
|
|
|
// Store references
|
|
sourceNodesRef.current.push(source);
|
|
gainNodesRef.current.push(gainNode);
|
|
panNodesRef.current.push(panNode);
|
|
|
|
// Handle ended event
|
|
source.onended = () => {
|
|
if (pausedAtRef.current + (audioContext.currentTime - startTimeRef.current) >= duration) {
|
|
setIsPlaying(false);
|
|
setCurrentTime(0);
|
|
pausedAtRef.current = 0;
|
|
}
|
|
};
|
|
}
|
|
|
|
startTimeRef.current = audioContext.currentTime;
|
|
setIsPlaying(true);
|
|
updatePlaybackPosition();
|
|
}, [tracks, duration, updatePlaybackPosition]);
|
|
|
|
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);
|
|
|
|
setIsPlaying(false);
|
|
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.current);
|
|
animationFrameRef.current = null;
|
|
}
|
|
}, [isPlaying, duration]);
|
|
|
|
const stop = useCallback(() => {
|
|
pause();
|
|
pausedAtRef.current = 0;
|
|
setCurrentTime(0);
|
|
}, [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
|
|
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]);
|
|
|
|
// Cleanup on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (animationFrameRef.current) {
|
|
cancelAnimationFrame(animationFrameRef.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());
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
isPlaying,
|
|
currentTime,
|
|
duration,
|
|
play,
|
|
pause,
|
|
stop,
|
|
seek,
|
|
togglePlayPause,
|
|
};
|
|
}
|