From d566a86c58ec878a76c11a15db828dc290ad0c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Mon, 17 Nov 2025 21:03:39 +0100 Subject: [PATCH] feat: implement multi-track playback system (Phase 7.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/hooks/useMultiTrackPlayer.ts | 228 +++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 lib/hooks/useMultiTrackPlayer.ts diff --git a/lib/hooks/useMultiTrackPlayer.ts b/lib/hooks/useMultiTrackPlayer.ts new file mode 100644 index 0000000..48aa70b --- /dev/null +++ b/lib/hooks/useMultiTrackPlayer.ts @@ -0,0 +1,228 @@ +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(null); + const sourceNodesRef = useRef([]); + const gainNodesRef = useRef([]); + const panNodesRef = useRef([]); + const startTimeRef = useRef(0); + const pausedAtRef = useRef(0); + const animationFrameRef = useRef(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, + }; +}