import { useState, useRef, useCallback, useEffect } from 'react' import { createRubberBandNode } from 'rubberband-web' const RUBBERBAND_WORKLET_URL = '/rubberband-processor.js' const LIVE_TRANSFORM_RAMP_MS = 140 const MEDIA_TRANSFORM_PREROLL_MS = 360 const MEDIA_TRANSFORM_RESYNC_SETTLE_MS = 90 const MEDIA_TRANSFORM_CROSSFADE_MS = 160 const DEFAULT_LIVE_TRANSFORM = { semitones: 0, originalBpm: null, targetBpm: null, bpmRatio: 1, pitchScale: 1, rubberBandPitchScale: 1, highQuality: true } /** * Core Web Audio API abstraction * * Creates and manages: * - AudioContext * - One AudioBufferSourceNode per stem (for playback) * - One GainNode per stem (for volume control) * - One master GainNode * - One AnalyserNode (for visualization) * * Graph: stem sources → individual gains → master gain → analyser → destination */ export function useAudioEngine() { const [isPlaying, setIsPlaying] = useState(false) const [currentTime, setCurrentTime] = useState(0) const [duration, setDuration] = useState(0) const [volumes, setVolumes] = useState({}) const [solos, setSolos] = useState({}) const [mutes, setMutes] = useState({}) const [analyserData, setAnalyserData] = useState(null) const [isLoaded, setIsLoaded] = useState(false) const [reverbs, setReverbs] = useState({}) // Reverb amount per stem (0-1) const [pans, setPans] = useState({}) // Pan position per stem (-1 to 1) const [liveTransform, setLiveTransformState] = useState(DEFAULT_LIVE_TRANSFORM) const audioContextRef = useRef(null) const buffersRef = useRef({}) // AudioBuffers (raw data) const mediaElementsRef = useRef({}) // HTMLAudioElements for streamable preset playback const mediaSourcesRef = useRef({}) // MediaElementAudioSourceNodes const sourceModeRef = useRef('buffer') const sourcesRef = useRef({}) // AudioBufferSourceNodes (one-shot, recreated each play) const rubberBandNodesRef = useRef({}) // Rubber Band AudioWorkletNodes per stem const mediaChainGainsRef = useRef({}) // Per-stem active media-chain crossfade gains const gainsRef = useRef({}) // GainNodes per stem const compressorsRef = useRef({}) // DynamicsCompressorNodes per stem const reverbGainsRef = useRef({}) // Reverb wet/dry mix GainNodes const reverbSendsRef = useRef({}) // Reverb send GainNodes const pannersRef = useRef({}) // StereoPannerNodes per stem const masterGainRef = useRef(null) // Master GainNode const analyserRef = useRef(null) // AnalyserNode for visualization const convolverRef = useRef(null) // Shared ConvolverNode for reverb const startTimeRef = useRef(0) // AudioContext time when playback started const pauseTimeRef = useRef(0) // Offset in seconds where we paused const animationRef = useRef(null) const lastTimeUpdateRef = useRef(0) // Last time we updated currentTime state (for throttling) const loopRef = useRef(false) // Whether to loop playback const rawRegionRef = useRef(null) // { start, end } in seconds for raw-region loop (no processing) const liveTransformRef = useRef(DEFAULT_LIVE_TRANSFORM) const isPlayingRef = useRef(false) const durationRef = useRef(0) const rubberBandWorkletLoadedRef = useRef(false) const mediaRateRampsRef = useRef(new Map()) const rubberBandPitchRampsRef = useRef(new Map()) const rubberBandPitchScalesRef = useRef(new Map()) const pendingMediaTransformRef = useRef(null) const mediaTransformRequestRef = useRef(0) const standbyMediaChainRef = useRef(null) const [isRawRegionActive, setIsRawRegionActive] = useState(false) // Persistent AudioBuffer cache: keys like "drums_full", "drums_region" → AudioBuffer // Survives across loadStems calls so we skip fetch+decode on replay const bufferCacheRef = useRef({}) const cancelMediaRateRamp = useCallback((audio) => { const rampId = mediaRateRampsRef.current.get(audio) if (rampId) cancelAnimationFrame(rampId) mediaRateRampsRef.current.delete(audio) }, []) const closeRubberBandNode = useCallback((node) => { if (!node) return const rampId = rubberBandPitchRampsRef.current.get(node) if (rampId) cancelAnimationFrame(rampId) rubberBandPitchRampsRef.current.delete(node) rubberBandPitchScalesRef.current.delete(node) try { node.disconnect() } catch (e) {} try { node.close?.() } catch (e) {} }, []) const disposeMediaChain = useCallback((chain) => { if (!chain) return Object.values(chain.elements || {}).forEach(audio => { cancelMediaRateRamp(audio) try { audio.pause() } catch (e) {} try { audio.removeAttribute('src') } catch (e) {} try { audio.load() } catch (e) {} }) Object.values(chain.sources || {}).forEach(source => { try { source.disconnect() } catch (e) {} }) Object.values(chain.nodes || {}).forEach(closeRubberBandNode) Object.values(chain.chainGains || {}).forEach(gain => { try { gain.disconnect() } catch (e) {} }) }, [cancelMediaRateRamp, closeRubberBandNode]) const cancelPendingMediaTransform = useCallback(() => { mediaTransformRequestRef.current += 1 const pending = pendingMediaTransformRef.current if (!pending) return Object.values(pending.oldChainGains || {}).forEach(gain => { try { const now = audioContextRef.current?.currentTime || 0 gain.gain.cancelScheduledValues(now) gain.gain.setValueAtTime(1, now) } catch (e) {} }) if (pending.reusable) { Object.values(pending.chainGains || {}).forEach(gain => { try { const now = audioContextRef.current?.currentTime || 0 gain.gain.cancelScheduledValues(now) gain.gain.setValueAtTime(0, now) } catch (e) {} }) } else { disposeMediaChain(pending) } pendingMediaTransformRef.current = null }, [disposeMediaChain]) const closeRubberBandNodes = useCallback(() => { Object.values(rubberBandNodesRef.current).forEach(closeRubberBandNode) Object.values(mediaChainGainsRef.current).forEach(gain => { try { gain.disconnect() } catch (e) {} }) rubberBandNodesRef.current = {} mediaChainGainsRef.current = {} }, [closeRubberBandNode]) const setMediaElementRate = useCallback((audio, transform = liveTransformRef.current, { smooth = false } = {}) => { if (!audio) return const targetRate = transform.bpmRatio || 1 audio.defaultPlaybackRate = targetRate try { audio.preservesPitch = false } catch (e) {} try { audio.mozPreservesPitch = false } catch (e) {} try { audio.webkitPreservesPitch = false } catch (e) {} const existingRamp = mediaRateRampsRef.current.get(audio) if (existingRamp) cancelAnimationFrame(existingRamp) const fromRate = Number.isFinite(audio.playbackRate) && audio.playbackRate > 0 ? audio.playbackRate : 1 if (!smooth || audio.paused || Math.abs(fromRate - targetRate) < 0.001) { audio.playbackRate = targetRate mediaRateRampsRef.current.delete(audio) return } const start = performance.now() const step = (now) => { const progress = Math.min(1, (now - start) / LIVE_TRANSFORM_RAMP_MS) const eased = 1 - Math.pow(1 - progress, 3) audio.playbackRate = fromRate + (targetRate - fromRate) * eased if (progress < 1 && !audio.paused) { mediaRateRampsRef.current.set(audio, requestAnimationFrame(step)) } else { audio.playbackRate = targetRate mediaRateRampsRef.current.delete(audio) } } mediaRateRampsRef.current.set(audio, requestAnimationFrame(step)) }, []) const applyLiveTransformToNode = useCallback((node, transform = liveTransformRef.current, { smooth = false } = {}) => { if (!node) return const targetPitch = transform.rubberBandPitchScale || 1 try { node.setHighQuality?.(transform.highQuality) } catch (e) {} const existingRamp = rubberBandPitchRampsRef.current.get(node) if (existingRamp) cancelAnimationFrame(existingRamp) const fromPitch = rubberBandPitchScalesRef.current.get(node) ?? targetPitch if (!smooth || Math.abs(fromPitch - targetPitch) < 0.001) { try { node.setPitch?.(targetPitch) } catch (e) {} rubberBandPitchScalesRef.current.set(node, targetPitch) rubberBandPitchRampsRef.current.delete(node) return } const start = performance.now() const step = (now) => { const progress = Math.min(1, (now - start) / LIVE_TRANSFORM_RAMP_MS) const eased = 1 - Math.pow(1 - progress, 3) const value = fromPitch + (targetPitch - fromPitch) * eased try { node.setPitch?.(value) } catch (e) {} rubberBandPitchScalesRef.current.set(node, value) if (progress < 1) { rubberBandPitchRampsRef.current.set(node, requestAnimationFrame(step)) } else { try { node.setPitch?.(targetPitch) } catch (e) {} rubberBandPitchScalesRef.current.set(node, targetPitch) rubberBandPitchRampsRef.current.delete(node) } } rubberBandPitchRampsRef.current.set(node, requestAnimationFrame(step)) }, []) const applyLiveTransformToSources = useCallback((transform = liveTransformRef.current, { smooth = false } = {}) => { const ctx = audioContextRef.current Object.values(mediaElementsRef.current).forEach(audio => { setMediaElementRate(audio, transform, { smooth }) }) Object.values(sourcesRef.current).forEach(source => { if (!source?.playbackRate) return const now = ctx?.currentTime || 0 if (smooth && ctx) { source.playbackRate.cancelScheduledValues(now) source.playbackRate.setValueAtTime(source.playbackRate.value, now) source.playbackRate.linearRampToValueAtTime(transform.bpmRatio, now + LIVE_TRANSFORM_RAMP_MS / 1000) } else { source.playbackRate.setValueAtTime(transform.bpmRatio, now) } }) }, [setMediaElementRate]) const ensureRubberBandWorklet = useCallback(async (ctx) => { if (rubberBandWorkletLoadedRef.current) return await ctx.audioWorklet.addModule(RUBBERBAND_WORKLET_URL) rubberBandWorkletLoadedRef.current = true }, []) // Generate impulse response for reverb const createReverbImpulse = useCallback((ctx, duration = 2, decay = 2) => { const sampleRate = ctx.sampleRate const length = sampleRate * duration const impulse = ctx.createBuffer(2, length, sampleRate) const left = impulse.getChannelData(0) const right = impulse.getChannelData(1) for (let i = 0; i < length; i++) { const n = i / sampleRate const envelope = Math.exp(-n * decay) left[i] = (Math.random() * 2 - 1) * envelope right[i] = (Math.random() * 2 - 1) * envelope } return impulse }, []) // Initialize audio context and graph const initAudio = useCallback(() => { if (!audioContextRef.current) { audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)() // Create master gain masterGainRef.current = audioContextRef.current.createGain() // Create shared reverb convolver convolverRef.current = audioContextRef.current.createConvolver() convolverRef.current.buffer = createReverbImpulse(audioContextRef.current, 2, 2.5) convolverRef.current.connect(masterGainRef.current) // Create analyser analyserRef.current = audioContextRef.current.createAnalyser() analyserRef.current.fftSize = 128 analyserRef.current.smoothingTimeConstant = 0.8 // Graph: master gain → analyser → destination masterGainRef.current.connect(analyserRef.current) analyserRef.current.connect(audioContextRef.current.destination) } return audioContextRef.current }, [createReverbImpulse]) const waitForStreamReady = useCallback((audio) => new Promise((resolve, reject) => { if (audio.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) { resolve() return } const cleanup = () => { audio.removeEventListener('canplay', onReady) audio.removeEventListener('loadedmetadata', onMetadata) audio.removeEventListener('error', onError) } const onReady = () => { cleanup() resolve() } const onMetadata = () => { if (audio.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) { cleanup() resolve() } } const onError = () => { cleanup() reject(new Error(`Could not load stream: ${audio.currentSrc || audio.src}`)) } audio.addEventListener('canplay', onReady, { once: true }) audio.addEventListener('loadedmetadata', onMetadata) audio.addEventListener('error', onError, { once: true }) }), []) const runMediaTransformHandoff = useCallback(async (nextTransform, previousTransform) => { const ctx = initAudio() cancelPendingMediaTransform() const requestId = mediaTransformRequestRef.current + 1 mediaTransformRequestRef.current = requestId const activeEntries = Object.entries(mediaElementsRef.current) if (activeEntries.length === 0) return const standby = standbyMediaChainRef.current if (!standby || Object.keys(standby.elements || {}).length === 0) { console.warn('Live transform standby chain is not ready yet; leaving current playback unchanged.') return } const previousRate = previousTransform?.bpmRatio || activeEntries[0][1]?.playbackRate || 1 const nextRate = nextTransform?.bpmRatio || 1 const prerollSeconds = MEDIA_TRANSFORM_PREROLL_MS / 1000 const rawRegion = rawRegionRef.current const minPosition = rawRegion ? rawRegion.start : 0 const maxPosition = rawRegion ? rawRegion.end : durationRef.current const clampPosition = (position) => Math.max(minPosition, Math.min(position, maxPosition || position)) const pending = { id: requestId, elements: standby.elements, sources: standby.sources, nodes: standby.nodes, chainGains: standby.chainGains, oldChainGains: null, reusable: true } pendingMediaTransformRef.current = pending try { Object.values(pending.chainGains).forEach(gain => { gain.gain.cancelScheduledValues(ctx.currentTime) gain.gain.setValueAtTime(0, ctx.currentTime) }) Object.entries(pending.nodes).forEach(([, node]) => { applyLiveTransformToNode(node, nextTransform) }) Object.entries(pending.elements).forEach(([, audio]) => { setMediaElementRate(audio, nextTransform) }) Object.entries(pending.elements).forEach(([stem, audio]) => { const activeAudio = mediaElementsRef.current[stem] const livePosition = Number.isFinite(activeAudio?.currentTime) ? activeAudio.currentTime : pauseTimeRef.current const alignedStart = livePosition + prerollSeconds * (previousRate - nextRate) try { audio.currentTime = clampPosition(alignedStart) } catch (e) {} }) await Promise.all(Object.values(pending.elements).map(audio => audio.paused ? audio.play() : Promise.resolve())) await new Promise(resolve => setTimeout(resolve, MEDIA_TRANSFORM_PREROLL_MS)) if (mediaTransformRequestRef.current !== requestId || pendingMediaTransformRef.current !== pending) { Object.values(pending.chainGains).forEach(gain => { try { gain.gain.setValueAtTime(0, ctx.currentTime) } catch (e) {} }) return } let resynced = false const settleSeconds = MEDIA_TRANSFORM_RESYNC_SETTLE_MS / 1000 Object.entries(pending.elements).forEach(([stem, audio]) => { const activeAudio = mediaElementsRef.current[stem] if (!activeAudio || !Number.isFinite(activeAudio.currentTime) || !Number.isFinite(audio.currentTime)) return const targetAtFade = activeAudio.currentTime + settleSeconds * (previousRate - nextRate) const timingError = Math.abs(targetAtFade - audio.currentTime) if (timingError > 0.015) { try { audio.currentTime = clampPosition(targetAtFade) resynced = true } catch (e) {} } }) if (resynced) { await new Promise(resolve => setTimeout(resolve, MEDIA_TRANSFORM_RESYNC_SETTLE_MS)) if (mediaTransformRequestRef.current !== requestId || pendingMediaTransformRef.current !== pending) { Object.values(pending.chainGains).forEach(gain => { try { gain.gain.setValueAtTime(0, ctx.currentTime) } catch (e) {} }) return } } const now = ctx.currentTime const fadeEnd = now + MEDIA_TRANSFORM_CROSSFADE_MS / 1000 const oldElements = mediaElementsRef.current const oldSources = mediaSourcesRef.current const oldNodes = rubberBandNodesRef.current const oldChainGains = mediaChainGainsRef.current pending.oldChainGains = oldChainGains Object.values(pending.chainGains).forEach(gain => { gain.gain.cancelScheduledValues(now) gain.gain.setValueAtTime(0, now) gain.gain.linearRampToValueAtTime(1, fadeEnd) }) Object.values(oldChainGains).forEach(gain => { gain.gain.cancelScheduledValues(now) gain.gain.setValueAtTime(gain.gain.value || 1, now) gain.gain.linearRampToValueAtTime(0, fadeEnd) }) setTimeout(() => { if (mediaTransformRequestRef.current !== requestId || pendingMediaTransformRef.current !== pending) return mediaElementsRef.current = pending.elements mediaSourcesRef.current = pending.sources rubberBandNodesRef.current = pending.nodes mediaChainGainsRef.current = pending.chainGains standbyMediaChainRef.current = { elements: oldElements, sources: oldSources, nodes: oldNodes, chainGains: oldChainGains } pendingMediaTransformRef.current = null const promotedFirst = Object.values(mediaElementsRef.current)[0] if (promotedFirst && Number.isFinite(promotedFirst.currentTime)) { pauseTimeRef.current = promotedFirst.currentTime setCurrentTime(promotedFirst.currentTime) } startTimeRef.current = ctx.currentTime Object.values(oldChainGains).forEach(gain => { try { gain.gain.setValueAtTime(0, ctx.currentTime) } catch (e) {} }) }, MEDIA_TRANSFORM_CROSSFADE_MS + 30) } catch (error) { if (pendingMediaTransformRef.current === pending) { pendingMediaTransformRef.current = null } Object.values(pending.chainGains || {}).forEach(gain => { try { gain.gain.setValueAtTime(0, ctx.currentTime) } catch (e) {} }) console.error('Live transform handoff failed:', error) } }, [ initAudio, cancelPendingMediaTransform, setMediaElementRate, applyLiveTransformToNode, disposeMediaChain ]) const loadStemUrls = useCallback(async (stemUrls) => { const totalStart = performance.now() console.log('=== STREAMING STEM LOADING START ===') console.log('Loading streamable stems:', stemUrls) const ctx = initAudio() sourceModeRef.current = 'media' cancelPendingMediaTransform() disposeMediaChain(standbyMediaChainRef.current) standbyMediaChainRef.current = null Object.values(sourcesRef.current).forEach(source => { try { source.stop() } catch (e) {} }) Object.values(mediaElementsRef.current).forEach(audio => { const rampId = mediaRateRampsRef.current.get(audio) if (rampId) cancelAnimationFrame(rampId) mediaRateRampsRef.current.delete(audio) try { audio.pause() } catch (e) {} try { audio.removeAttribute('src') } catch (e) {} try { audio.load() } catch (e) {} }) Object.values(mediaSourcesRef.current).forEach(source => { try { source.disconnect() } catch (e) {} }) closeRubberBandNodes() Object.values(gainsRef.current).forEach(g => g?.disconnect()) Object.values(compressorsRef.current).forEach(c => c?.disconnect()) Object.values(pannersRef.current).forEach(p => p?.disconnect()) Object.values(reverbGainsRef.current).forEach(r => r?.disconnect()) Object.values(reverbSendsRef.current).forEach(r => r?.disconnect()) sourcesRef.current = {} buffersRef.current = {} mediaElementsRef.current = {} mediaSourcesRef.current = {} mediaChainGainsRef.current = {} gainsRef.current = {} compressorsRef.current = {} pannersRef.current = {} reverbGainsRef.current = {} reverbSendsRef.current = {} setIsLoaded(false) const entries = Array.isArray(stemUrls) ? stemUrls.map(item => [item.name || item.stem, item.url]) : Object.entries(stemUrls || {}) const newVolumes = {} entries.forEach(([stem, url]) => { if (!stem || !url) return const audio = new Audio() audio.crossOrigin = 'anonymous' audio.preload = 'auto' audio.src = url setMediaElementRate(audio) mediaElementsRef.current[stem] = audio gainsRef.current[stem] = ctx.createGain() compressorsRef.current[stem] = ctx.createDynamicsCompressor() compressorsRef.current[stem].threshold.value = -24 compressorsRef.current[stem].knee.value = 30 compressorsRef.current[stem].ratio.value = 4 compressorsRef.current[stem].attack.value = 0.003 compressorsRef.current[stem].release.value = 0.25 pannersRef.current[stem] = ctx.createStereoPanner() const stemLower = stem.toLowerCase() let defaultPan = 0 if (stemLower.includes('bass')) defaultPan = 0 else if (stemLower.includes('drum')) defaultPan = 0 else if (stemLower.includes('guitar')) defaultPan = -0.3 else if (stemLower.includes('synth')) defaultPan = 0.3 else if (stemLower.includes('keys')) defaultPan = 0.2 else if (stemLower.includes('vocal')) defaultPan = 0 else defaultPan = (Math.random() - 0.5) * 0.4 pannersRef.current[stem].pan.value = defaultPan reverbSendsRef.current[stem] = ctx.createGain() reverbSendsRef.current[stem].gain.value = 0 gainsRef.current[stem].connect(compressorsRef.current[stem]) compressorsRef.current[stem].connect(pannersRef.current[stem]) pannersRef.current[stem].connect(masterGainRef.current) pannersRef.current[stem].connect(reverbSendsRef.current[stem]) reverbSendsRef.current[stem].connect(convolverRef.current) mediaSourcesRef.current[stem] = ctx.createMediaElementSource(audio) mediaSourcesRef.current[stem].connect(gainsRef.current[stem]) newVolumes[stem] = 1 audio.load() }) await Promise.all(Object.values(mediaElementsRef.current).map(waitForStreamReady)) let maxDuration = 0 Object.values(mediaElementsRef.current).forEach(audio => { if (Number.isFinite(audio.duration) && audio.duration > maxDuration) { maxDuration = audio.duration } }) const newReverbs = {} const newPans = {} Object.keys(mediaElementsRef.current).forEach(stem => { newReverbs[stem] = 0.0 newPans[stem] = pannersRef.current[stem]?.pan.value || 0 }) setDuration(maxDuration) setVolumes(newVolumes) setReverbs(newReverbs) setPans(newPans) setSolos({}) setMutes({}) setIsLoaded(true) pauseTimeRef.current = 0 setCurrentTime(0) console.log(`=== STREAMING STEM LOADING COMPLETE in ${(performance.now() - totalStart).toFixed(0)}ms ===`) }, [initAudio, cancelPendingMediaTransform, disposeMediaChain, closeRubberBandNodes, waitForStreamReady, setMediaElementRate]) // Load stems directly from cached IndexedDB bytes — no network fetch needed. // stemsData shape: { stemName: { bytes: ArrayBuffer, sampleRate, numChannels, numFrames } } const loadStemsFromBytes = useCallback(async (stemsData) => { const totalStart = performance.now() console.log('=== STEM LOADING FROM CACHE START ===') const ctx = initAudio() sourceModeRef.current = 'buffer' cancelPendingMediaTransform() disposeMediaChain(standbyMediaChainRef.current) standbyMediaChainRef.current = null // Disconnect existing audio graph nodes Object.values(mediaElementsRef.current).forEach(audio => { const rampId = mediaRateRampsRef.current.get(audio) if (rampId) cancelAnimationFrame(rampId) mediaRateRampsRef.current.delete(audio) try { audio.pause() } catch (e) {} try { audio.removeAttribute('src') } catch (e) {} try { audio.load() } catch (e) {} }) Object.values(mediaSourcesRef.current).forEach(source => { try { source.disconnect() } catch (e) {} }) closeRubberBandNodes() Object.values(gainsRef.current).forEach(g => g?.disconnect()) Object.values(compressorsRef.current).forEach(c => c?.disconnect()) Object.values(pannersRef.current).forEach(p => p?.disconnect()) Object.values(reverbGainsRef.current).forEach(r => r?.disconnect()) Object.values(reverbSendsRef.current).forEach(r => r?.disconnect()) buffersRef.current = {} mediaElementsRef.current = {} mediaSourcesRef.current = {} mediaChainGainsRef.current = {} gainsRef.current = {} compressorsRef.current = {} pannersRef.current = {} reverbGainsRef.current = {} reverbSendsRef.current = {} setIsLoaded(false) const newVolumes = {} let maxDuration = 0 for (const [stem, { bytes, sampleRate, numChannels, numFrames }] of Object.entries(stemsData)) { const int16 = new Int16Array(bytes) const float32 = new Float32Array(int16.length) for (let i = 0; i < int16.length; i++) { float32[i] = int16[i] / 32767 } const audioBuffer = ctx.createBuffer(numChannels, numFrames, sampleRate) audioBuffer.copyToChannel(float32, 0) buffersRef.current[stem] = audioBuffer if (audioBuffer.duration > maxDuration) maxDuration = audioBuffer.duration newVolumes[stem] = 1 // Effect chain: source → Rubber Band → gain → compressor → panner → (dry + wet reverb) → master gainsRef.current[stem] = ctx.createGain() compressorsRef.current[stem] = ctx.createDynamicsCompressor() compressorsRef.current[stem].threshold.value = -24 compressorsRef.current[stem].knee.value = 30 compressorsRef.current[stem].ratio.value = 4 compressorsRef.current[stem].attack.value = 0.003 compressorsRef.current[stem].release.value = 0.25 pannersRef.current[stem] = ctx.createStereoPanner() const stemLower = stem.toLowerCase() let defaultPan = 0 if (stemLower.includes('bass')) defaultPan = 0 else if (stemLower.includes('drum')) defaultPan = 0 else if (stemLower.includes('guitar')) defaultPan = -0.3 else if (stemLower.includes('synth')) defaultPan = 0.3 else if (stemLower.includes('keys')) defaultPan = 0.2 else if (stemLower.includes('vocal')) defaultPan = 0 else defaultPan = (Math.random() - 0.5) * 0.4 pannersRef.current[stem].pan.value = defaultPan reverbSendsRef.current[stem] = ctx.createGain() reverbSendsRef.current[stem].gain.value = 0 gainsRef.current[stem].connect(compressorsRef.current[stem]) compressorsRef.current[stem].connect(pannersRef.current[stem]) pannersRef.current[stem].connect(masterGainRef.current) pannersRef.current[stem].connect(reverbSendsRef.current[stem]) reverbSendsRef.current[stem].connect(convolverRef.current) } const newReverbs = {} const newPans = {} Object.keys(buffersRef.current).forEach(stem => { newReverbs[stem] = 0.0 newPans[stem] = pannersRef.current[stem]?.pan.value || 0 }) setDuration(maxDuration) setVolumes(newVolumes) setReverbs(newReverbs) setPans(newPans) setSolos({}) setMutes({}) setIsLoaded(true) pauseTimeRef.current = 0 console.log(`=== STEM LOADING FROM CACHE COMPLETE in ${(performance.now() - totalStart).toFixed(0)}ms ===`) }, [initAudio, cancelPendingMediaTransform, disposeMediaChain, closeRubberBandNodes]) // Get frequency data for visualization const getAnalyserData = useCallback(() => { if (!analyserRef.current) return null const data = new Uint8Array(analyserRef.current.frequencyBinCount) analyserRef.current.getByteFrequencyData(data) return data }, []) // Keep refs in sync with state useEffect(() => { isPlayingRef.current = isPlaying }, [isPlaying]) useEffect(() => { durationRef.current = duration }, [duration]) // Animation loop for visualization and time updates useEffect(() => { let running = true const updateLoop = (timestamp) => { if (!running) return // Update analyser data every frame (60fps for smooth visualization) setAnalyserData(getAnalyserData()) // Update current time - throttled to every 100ms for performance if (audioContextRef.current && isPlayingRef.current) { const firstMedia = sourceModeRef.current === 'media' ? Object.values(mediaElementsRef.current)[0] : null const elapsed = firstMedia ? firstMedia.currentTime : (audioContextRef.current.currentTime - startTimeRef.current) * liveTransformRef.current.bpmRatio + pauseTimeRef.current const rawRegion = rawRegionRef.current // For raw region loops, treat region end as the effective song end const effectiveDur = rawRegion ? rawRegion.end : durationRef.current const newTime = Math.min(elapsed, effectiveDur) // Only update state every 100ms (10fps) to reduce re-renders const timeSinceLastUpdate = timestamp - lastTimeUpdateRef.current if (timeSinceLastUpdate >= 100) { setCurrentTime(newTime) lastTimeUpdateRef.current = timestamp } // Check if playback ended if (elapsed >= effectiveDur && effectiveDur > 0) { if (loopRef.current) { const loopStart = rawRegion ? rawRegion.start : 0 const loopDur = rawRegion ? (rawRegion.end - rawRegion.start) : undefined pauseTimeRef.current = loopStart setCurrentTime(loopStart) if (sourceModeRef.current === 'media') { Object.values(mediaElementsRef.current).forEach(audio => { audio.currentTime = loopStart setMediaElementRate(audio) audio.play().catch(err => console.warn('Loop restart failed:', err)) }) Object.values(standbyMediaChainRef.current?.elements || {}).forEach(audio => { audio.currentTime = loopStart setMediaElementRate(audio) audio.play().catch(err => console.warn('Standby loop restart failed:', err)) }) startTimeRef.current = audioContextRef.current.currentTime } else { // Loop: restart from region start (or song beginning) Object.values(sourcesRef.current).forEach(source => { try { source.stop() } catch (e) {} }) sourcesRef.current = {} const ctx = audioContextRef.current Object.entries(buffersRef.current).forEach(([stem, buffer]) => { if (!buffer || !gainsRef.current[stem]) return const source = ctx.createBufferSource() source.buffer = buffer source.playbackRate.setValueAtTime(liveTransformRef.current.bpmRatio, ctx.currentTime) source.connect(rubberBandNodesRef.current[stem] || gainsRef.current[stem]) sourcesRef.current[stem] = source source.start(0, loopStart, loopDur) }) startTimeRef.current = ctx.currentTime } } else { // Stop playback Object.values(mediaElementsRef.current).forEach(audio => { try { audio.pause() } catch (e) {} try { audio.currentTime = 0 } catch (e) {} }) Object.values(standbyMediaChainRef.current?.elements || {}).forEach(audio => { try { audio.pause() } catch (e) {} try { audio.currentTime = 0 } catch (e) {} }) Object.values(sourcesRef.current).forEach(source => { try { source.stop() } catch (e) {} }) sourcesRef.current = {} pauseTimeRef.current = 0 startTimeRef.current = 0 setCurrentTime(0) setIsPlaying(false) return } } } animationRef.current = requestAnimationFrame(updateLoop) } // Start the loop animationRef.current = requestAnimationFrame(updateLoop) return () => { running = false if (animationRef.current) { cancelAnimationFrame(animationRef.current) } } }, [getAnalyserData]) // Update gain nodes based on volume/solo/mute state (instant, no server call) useEffect(() => { const hasSolos = Object.values(solos).some(s => s) Object.entries(gainsRef.current).forEach(([stem, gain]) => { if (!gain) return const volume = volumes[stem] ?? 1 const isMuted = mutes[stem] ?? false const isSolo = solos[stem] ?? false // Solo/Mute logic per spec: // - Mute: set gain to 0 regardless // - Solo: only soloed stems play (set others to 0) // - Mute overrides solo let targetGain = volume if (isMuted) { targetGain = 0 } else if (hasSolos && !isSolo) { targetGain = 0 } gain.gain.setValueAtTime(targetGain, audioContextRef.current?.currentTime || 0) }) }, [volumes, solos, mutes]) // Update reverb and pan in real-time useEffect(() => { Object.entries(reverbSendsRef.current).forEach(([stem, reverbSend]) => { if (!reverbSend) return const reverbAmount = reverbs[stem] ?? 0.0 reverbSend.gain.setValueAtTime(reverbAmount, audioContextRef.current?.currentTime || 0) }) Object.entries(pannersRef.current).forEach(([stem, panner]) => { if (!panner) return const panValue = pans[stem] ?? 0 panner.pan.setValueAtTime(panValue, audioContextRef.current?.currentTime || 0) }) }, [reverbs, pans]) const createRubberBandChain = useCallback(async () => { const ctx = initAudio() disposeMediaChain(standbyMediaChainRef.current) standbyMediaChainRef.current = null closeRubberBandNodes() await ensureRubberBandWorklet(ctx) const entries = sourceModeRef.current === 'media' ? Object.keys(mediaElementsRef.current) : Object.keys(buffersRef.current) for (const stem of entries) { if (!gainsRef.current[stem]) continue const node = await createRubberBandNode(ctx, RUBBERBAND_WORKLET_URL, { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1], channelCount: 1, channelCountMode: 'explicit', channelInterpretation: 'speakers' }) const chainGain = ctx.createGain() chainGain.gain.value = 1 applyLiveTransformToNode(node) node.connect(chainGain) chainGain.connect(gainsRef.current[stem]) rubberBandNodesRef.current[stem] = node mediaChainGainsRef.current[stem] = chainGain if (sourceModeRef.current === 'media' && mediaSourcesRef.current[stem]) { try { mediaSourcesRef.current[stem].disconnect() } catch (e) {} mediaSourcesRef.current[stem].connect(node) } } if (sourceModeRef.current === 'media') { const standby = { elements: {}, sources: {}, nodes: {}, chainGains: {} } for (const [stem, activeAudio] of Object.entries(mediaElementsRef.current)) { if (!gainsRef.current[stem]) continue const url = activeAudio.currentSrc || activeAudio.src if (!url) continue const audio = new Audio() audio.crossOrigin = activeAudio.crossOrigin || 'anonymous' audio.preload = 'auto' audio.src = url setMediaElementRate(audio) audio.load() const source = ctx.createMediaElementSource(audio) const node = await createRubberBandNode(ctx, RUBBERBAND_WORKLET_URL, { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1], channelCount: 1, channelCountMode: 'explicit', channelInterpretation: 'speakers' }) const chainGain = ctx.createGain() chainGain.gain.value = 0 applyLiveTransformToNode(node) source.connect(node) node.connect(chainGain) chainGain.connect(gainsRef.current[stem]) standby.elements[stem] = audio standby.sources[stem] = source standby.nodes[stem] = node standby.chainGains[stem] = chainGain } if (Object.keys(standby.elements).length > 0) { await Promise.all(Object.values(standby.elements).map(waitForStreamReady)) standbyMediaChainRef.current = standby } else { disposeMediaChain(standby) } } }, [ initAudio, disposeMediaChain, closeRubberBandNodes, ensureRubberBandWorklet, applyLiveTransformToNode, setMediaElementRate, waitForStreamReady ]) const setLiveTransform = useCallback(({ semitones = 0, originalBpm = null, targetBpm = null, highQuality = true } = {}) => { const ctx = audioContextRef.current const previous = liveTransformRef.current if (ctx && isPlayingRef.current) { if (sourceModeRef.current === 'media') { const firstMedia = Object.values(mediaElementsRef.current)[0] if (firstMedia && Number.isFinite(firstMedia.currentTime)) { pauseTimeRef.current = firstMedia.currentTime } } else if (startTimeRef.current) { pauseTimeRef.current += (ctx.currentTime - startTimeRef.current) * previous.bpmRatio } startTimeRef.current = ctx.currentTime setCurrentTime(pauseTimeRef.current) } const cleanSemitones = Number.isFinite(Number(semitones)) ? Number(semitones) : 0 const cleanOriginalBpm = Number.isFinite(Number(originalBpm)) && Number(originalBpm) > 0 ? Number(originalBpm) : null const cleanTargetBpm = Number.isFinite(Number(targetBpm)) && Number(targetBpm) > 0 ? Number(targetBpm) : cleanOriginalBpm const bpmRatio = cleanOriginalBpm && cleanTargetBpm ? cleanTargetBpm / cleanOriginalBpm : 1 const pitchScale = Math.pow(2, cleanSemitones / 12) const rubberBandPitchScale = pitchScale / bpmRatio const next = { semitones: cleanSemitones, originalBpm: cleanOriginalBpm, targetBpm: cleanTargetBpm, bpmRatio, pitchScale, rubberBandPitchScale, highQuality } liveTransformRef.current = next setLiveTransformState(next) if (sourceModeRef.current === 'media' && isPlayingRef.current) { runMediaTransformHandoff(next, previous) return } const smooth = isPlayingRef.current Object.values(rubberBandNodesRef.current).forEach(node => applyLiveTransformToNode(node, next, { smooth })) applyLiveTransformToSources(next, { smooth }) }, [runMediaTransformHandoff, applyLiveTransformToNode, applyLiveTransformToSources]) // Play: starts all sources simultaneously const play = useCallback(async () => { const hasSources = sourceModeRef.current === 'media' ? Object.keys(mediaElementsRef.current).length > 0 : Object.keys(buffersRef.current).length > 0 if (!isLoaded || !hasSources) { console.log('Cannot play: no stems loaded') return } const ctx = initAudio() // Resume context if suspended (browser autoplay policy) // Must await — source.start() on a suspended context is silent in Firefox/Safari if (ctx.state === 'suspended') { await ctx.resume() } // Stop any existing sources Object.values(sourcesRef.current).forEach(source => { try { source.stop() } catch (e) {} }) sourcesRef.current = {} await createRubberBandChain() if (sourceModeRef.current === 'media') { const rawRegion = rawRegionRef.current const startAt = rawRegion ? Math.max(rawRegion.start, Math.min(pauseTimeRef.current || rawRegion.start, rawRegion.end)) : pauseTimeRef.current await Promise.all(Object.values(mediaElementsRef.current).map(async audio => { setMediaElementRate(audio) audio.currentTime = startAt await audio.play() })) await Promise.all(Object.values(standbyMediaChainRef.current?.elements || {}).map(async audio => { setMediaElementRate(audio) audio.currentTime = startAt await audio.play() })) startTimeRef.current = ctx.currentTime isPlayingRef.current = true setIsPlaying(true) console.log('Streaming play started, startTime:', startTimeRef.current) return } // Create new source nodes for each stem (AudioBufferSourceNode is one-shot) const rawRegion = rawRegionRef.current Object.entries(buffersRef.current).forEach(([stem, buffer]) => { if (!buffer || !gainsRef.current[stem]) return const source = ctx.createBufferSource() source.buffer = buffer source.playbackRate.setValueAtTime(liveTransformRef.current.bpmRatio, ctx.currentTime) source.connect(rubberBandNodesRef.current[stem] || gainsRef.current[stem]) sourcesRef.current[stem] = source if (rawRegion) { // Confine playback to the selected region const remaining = Math.max(0, rawRegion.end - pauseTimeRef.current) source.start(0, pauseTimeRef.current, remaining) } else { source.start(0, pauseTimeRef.current) } }) startTimeRef.current = ctx.currentTime isPlayingRef.current = true // Update ref immediately (no waiting for useEffect) setIsPlaying(true) console.log('Play started, startTime:', startTimeRef.current) }, [initAudio, isLoaded, createRubberBandChain]) // Pause: suspends playback, saves position const pause = useCallback(() => { console.log('Pause called') cancelPendingMediaTransform() isPlayingRef.current = false // Update ref immediately if (sourceModeRef.current === 'media') { const firstMedia = Object.values(mediaElementsRef.current)[0] if (firstMedia) { pauseTimeRef.current = firstMedia.currentTime } Object.values(mediaElementsRef.current).forEach(audio => { try { audio.pause() } catch (e) {} }) Object.values(standbyMediaChainRef.current?.elements || {}).forEach(audio => { try { audio.pause() } catch (e) {} }) setIsPlaying(false) return } // Stop all sources Object.values(sourcesRef.current).forEach(source => { try { source.stop() } catch (e) {} }) sourcesRef.current = {} // Save pause position if (audioContextRef.current && startTimeRef.current) { pauseTimeRef.current += (audioContextRef.current.currentTime - startTimeRef.current) * liveTransformRef.current.bpmRatio } setIsPlaying(false) }, [cancelPendingMediaTransform]) // Stop: stops all sources, resets position const stop = useCallback(() => { cancelPendingMediaTransform() Object.values(mediaElementsRef.current).forEach(audio => { try { audio.pause() } catch (e) {} try { audio.currentTime = 0 } catch (e) {} }) Object.values(standbyMediaChainRef.current?.elements || {}).forEach(audio => { try { audio.pause() } catch (e) {} try { audio.currentTime = 0 } catch (e) {} }) Object.values(sourcesRef.current).forEach(source => { try { source.stop() } catch (e) {} }) sourcesRef.current = {} closeRubberBandNodes() disposeMediaChain(standbyMediaChainRef.current) standbyMediaChainRef.current = null mediaChainGainsRef.current = {} pauseTimeRef.current = 0 startTimeRef.current = 0 setCurrentTime(0) setIsPlaying(false) }, [cancelPendingMediaTransform, closeRubberBandNodes, disposeMediaChain]) // Seek: stop and restart from given position const seek = useCallback((time) => { cancelPendingMediaTransform() const wasPlaying = isPlaying // Optimistic update: set position immediately for instant visual feedback const newPosition = Math.max(0, Math.min(time, duration)) pauseTimeRef.current = newPosition setCurrentTime(newPosition) if (sourceModeRef.current === 'media') { Object.values(mediaElementsRef.current).forEach(audio => { audio.currentTime = newPosition }) Object.values(standbyMediaChainRef.current?.elements || {}).forEach(audio => { audio.currentTime = newPosition }) if (!wasPlaying) { setIsPlaying(false) } return } // Stop current playback Object.values(sourcesRef.current).forEach(source => { try { source.stop() } catch (e) {} }) sourcesRef.current = {} setIsPlaying(false) // Resume if was playing (immediately, no delay) if (wasPlaying) { play() } }, [cancelPendingMediaTransform, isPlaying, duration, play]) // setVolume: sets GainNode value (0-1), instant, no server call const setVolume = useCallback((stemName, value) => { setVolumes(prev => ({ ...prev, [stemName]: value })) }, []) // setSolo const setSolo = useCallback((stemName, soloed) => { setSolos(prev => ({ ...prev, [stemName]: soloed })) }, []) // setMute const setMute = useCallback((stemName, muted) => { setMutes(prev => ({ ...prev, [stemName]: muted })) }, []) // setReverb: sets reverb amount (0-1), instant const setReverb = useCallback((stemName, value) => { setReverbs(prev => ({ ...prev, [stemName]: value })) }, []) // setPan: sets stereo position (-1 to 1), instant const setPan = useCallback((stemName, value) => { setPans(prev => ({ ...prev, [stemName]: value })) }, []) // Set loop mode const setLoop = useCallback((enabled) => { loopRef.current = enabled }, []) // Set raw region for looping a selection without processing. // When active, play() confines audio to [start, end] and loops there. const setRawRegion = useCallback((start, end) => { if (start !== null && end !== null) { rawRegionRef.current = { start, end } setIsRawRegionActive(true) // Snap playhead to region start if it's currently outside the region if (pauseTimeRef.current < start || pauseTimeRef.current >= end) { pauseTimeRef.current = start setCurrentTime(start) } } else { rawRegionRef.current = null setIsRawRegionActive(false) } }, []) // Clear cached AudioBuffers for a specific tag ('full', 'region', or all) const clearBufferCache = useCallback((tag = null) => { if (tag) { const suffix = `_${tag}` for (const key of Object.keys(bufferCacheRef.current)) { if (key.endsWith(suffix)) { delete bufferCacheRef.current[key] } } console.log(`Buffer cache cleared for tag: ${tag}`) } else { bufferCacheRef.current = {} console.log('Buffer cache fully cleared') } }, []) // Reset all volumes to 1.0 const resetVolumes = useCallback(() => { const reset = {} const activeStems = sourceModeRef.current === 'media' ? Object.keys(mediaElementsRef.current) : Object.keys(buffersRef.current) activeStems.forEach(stem => { reset[stem] = 1 }) setVolumes(reset) setSolos({}) setMutes({}) }, []) return { // State isPlaying, isLoaded, isRawRegionActive, currentTime, duration, volumes, solos, mutes, reverbs, pans, liveTransform, analyserData, // Methods loadStemUrls, loadStemsFromBytes, play, pause, stop, seek, setVolume, setSolo, setMute, setReverb, setPan, setLiveTransform, resetVolumes, setLoop, setRawRegion, clearBufferCache, getAnalyserData } }