jam-tracks / frontend /src /hooks /useAudioEngine.js
Mina Emadi
Add seamless standby transform handoff
f9d9f2d
Raw History Blame Contribute Delete
48.2 kB
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
}
}