Spaces:
Sleeping
Sleeping
Download frontend/src/hooks/useAudioEngine.js from MusoraProductDepartment/jam-tracks: direct link, hf CLI and curl.
- Browser
- Download file 48.2 kB
-
https://huggingface.co/spaces/MusoraProductDepartment/jam-tracks/resolve/main/frontend/src/hooks/useAudioEngine.js
- Command line
-
hf download hf://spaces/MusoraProductDepartment/jam-tracks/frontend/src/hooks/useAudioEngine.js
-
curl -L -o useAudioEngine.js https://huggingface.co/spaces/MusoraProductDepartment/jam-tracks/resolve/main/frontend/src/hooks/useAudioEngine.js
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 | |
| } | |
| } | |