File size: 25,917 Bytes
a0fcd39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b384007
 
a0fcd39
 
 
 
 
b384007
 
 
 
a0fcd39
 
b384007
a0fcd39
 
 
b384007
 
35fde27
 
b384007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0fcd39
 
 
 
 
 
 
 
 
b384007
 
 
 
 
a0fcd39
 
 
 
 
 
 
 
 
 
b384007
a0fcd39
 
b384007
 
 
a0fcd39
b384007
 
 
a0fcd39
 
 
 
b384007
a0fcd39
b384007
 
 
 
 
a0fcd39
b384007
 
 
 
a0fcd39
 
b384007
a0fcd39
b384007
 
 
 
 
 
 
 
3ef0232
a0fcd39
 
3ef0232
a0fcd39
b384007
3ef0232
a0fcd39
3ef0232
a0fcd39
 
 
 
 
3ef0232
 
 
 
a0fcd39
 
 
 
 
 
3ef0232
46430b2
 
 
 
 
3ef0232
 
 
 
a0fcd39
b384007
 
 
a0fcd39
 
 
 
 
 
 
 
 
 
 
184639f
 
a0fcd39
b384007
 
a0fcd39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b384007
 
 
 
a0fcd39
b384007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0fcd39
 
 
b384007
 
 
 
 
 
 
 
a0fcd39
 
b384007
 
a0fcd39
 
 
 
 
 
 
 
 
 
 
d2f89e9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0fcd39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b384007
a0fcd39
 
b384007
a0fcd39
 
b384007
a0fcd39
 
35fde27
 
 
 
b384007
 
 
 
 
 
a0fcd39
 
 
35fde27
b384007
35fde27
b384007
 
 
 
 
35fde27
 
 
 
 
 
b384007
 
 
 
 
 
 
35fde27
b384007
 
 
 
 
 
 
 
 
 
 
 
 
 
a0fcd39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b384007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0fcd39
af10b74
a0fcd39
 
 
 
 
 
 
 
af10b74
a0fcd39
af10b74
a0fcd39
 
 
 
 
 
 
 
 
35fde27
a0fcd39
 
 
 
 
 
 
 
35fde27
 
 
 
 
 
 
a0fcd39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b384007
 
 
 
 
a0fcd39
 
 
 
 
 
 
 
b384007
a0fcd39
b384007
a0fcd39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b384007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35fde27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b384007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a0fcd39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35fde27
a0fcd39
 
 
 
 
b384007
 
a0fcd39
 
 
 
d2f89e9
a0fcd39
 
 
 
 
 
 
b384007
 
a0fcd39
b384007
35fde27
b384007
a0fcd39
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
import { useState, useRef, useCallback, useEffect } from 'react'

/**
 * 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 audioContextRef = useRef(null)
  const buffersRef = useRef({})       // AudioBuffers (raw data)
  const sourcesRef = useRef({})       // AudioBufferSourceNodes (one-shot, recreated each play)
  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 [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({})

  // 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])

  // Load stems from server: fetches WAVs IN PARALLEL, decodes to AudioBuffers
  // Uses persistent bufferCacheRef to skip fetch+decode on replay
  // If region=true, fetches region-processed stems instead of full-processed
  const loadStems = useCallback(async (sessionId, stemNames, { region = false } = {}) => {
    const totalStart = performance.now()
    const cacheTag = region ? 'region' : 'full'
    console.log(`=== STEM LOADING START (${cacheTag}) ===`)
    console.log('Loading stems:', stemNames)

    const ctx = initAudio()
    console.log(`AudioContext state: ${ctx.state}, sampleRate: ${ctx.sampleRate}`)

    // Disconnect existing audio graph nodes (cheap, must rebuild per-load)
    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 = {}
    gainsRef.current = {}
    compressorsRef.current = {}
    pannersRef.current = {}
    reverbGainsRef.current = {}
    reverbSendsRef.current = {}
    setIsLoaded(false)

    // For each stem, check the persistent cache first
    const loadSingleStem = async (stem) => {
      const cacheKey = `${stem}_${cacheTag}`

      // Check persistent cache
      if (bufferCacheRef.current[cacheKey]) {
        console.log(`[${stem}] CACHE HIT (${cacheTag})`)
        return { stem, audioBuffer: bufferCacheRef.current[cacheKey] }
      }

      // Cache miss β€” fetch raw PCM and construct AudioBuffer directly (no decodeAudioData)
      const stemStart = performance.now()
      try {
        console.log(`[${stem}] CACHE MISS β€” fetching PCM...`)
        const fetchStart = performance.now()
        const regionParam = region ? '&region=true' : ''
        let response = await fetch(`/api/stem/${sessionId}/${stem}?processed=true${regionParam}&format=pcm`)
        if (!response.ok) {
          response = await fetch(`/api/stem/${sessionId}/${stem}?processed=false&format=pcm`)
        }
        const fetchEnd = performance.now()
        console.log(`[${stem}] Fetch completed in ${(fetchEnd - fetchStart).toFixed(0)}ms`)

        if (response.ok) {
          const sampleRate = parseInt(response.headers.get('X-Sample-Rate'))
          const numChannels = parseInt(response.headers.get('X-Channels'))
          const numFrames = parseInt(response.headers.get('X-Frames'))

          const bufferStart = performance.now()
          const arrayBuffer = await response.arrayBuffer()
          const bufferEnd = performance.now()
          const sizeMB = (arrayBuffer.byteLength / 1024 / 1024).toFixed(2)
          console.log(`[${stem}] ArrayBuffer: ${sizeMB}MB in ${(bufferEnd - bufferStart).toFixed(0)}ms`)

          const constructStart = performance.now()
          const int16 = new Int16Array(arrayBuffer)
          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)
          const constructEnd = performance.now()
          console.log(`[${stem}] Constructed ${audioBuffer.duration.toFixed(1)}s AudioBuffer in ${(constructEnd - constructStart).toFixed(0)}ms`)

          // Store in persistent cache
          bufferCacheRef.current[cacheKey] = audioBuffer

          const stemEnd = performance.now()
          console.log(`[${stem}] TOTAL: ${(stemEnd - stemStart).toFixed(0)}ms`)

          return { stem, audioBuffer }
        }
      } catch (err) {
        console.error(`[${stem}] FAILED:`, err)
      }
      return null
    }

    // Load all stems in parallel
    console.log('Starting stem loading (all at once)...')
    const parallelStart = performance.now()
    const batchResults = await Promise.all(stemNames.map(loadSingleStem))
    const results = batchResults

    const parallelEnd = performance.now()
    console.log(`All stems loaded in ${(parallelEnd - parallelStart).toFixed(0)}ms`)

    const newVolumes = {}
    let maxDuration = 0

    // Process results
    for (const result of results) {
      if (result) {
        const { stem, audioBuffer } = result
        buffersRef.current[stem] = audioBuffer

        if (audioBuffer.duration > maxDuration) {
          maxDuration = audioBuffer.duration
        }

        newVolumes[stem] = 1

        // Create effect chain for this stem
        // Chain: source β†’ gain β†’ compressor β†’ panner β†’ (dry + wet reverb) β†’ master

        // 1. Gain node for volume control
        gainsRef.current[stem] = ctx.createGain()

        // 2. Compressor to reduce artifacts and tame dynamics
        compressorsRef.current[stem] = ctx.createDynamicsCompressor()
        compressorsRef.current[stem].threshold.value = -24  // Start compression at -24dB
        compressorsRef.current[stem].knee.value = 30        // Soft knee
        compressorsRef.current[stem].ratio.value = 4        // 4:1 ratio
        compressorsRef.current[stem].attack.value = 0.003   // Fast attack (3ms)
        compressorsRef.current[stem].release.value = 0.25   // Medium release (250ms)

        // 3. Stereo panner for spatialization
        pannersRef.current[stem] = ctx.createStereoPanner()
        // Set default panning based on instrument type
        const stemLower = stem.toLowerCase()
        let defaultPan = 0
        if (stemLower.includes('bass')) defaultPan = 0        // Center
        else if (stemLower.includes('drum')) defaultPan = 0   // Center
        else if (stemLower.includes('guitar')) defaultPan = -0.3  // Left
        else if (stemLower.includes('synth')) defaultPan = 0.3    // Right
        else if (stemLower.includes('keys')) defaultPan = 0.2     // Right
        else if (stemLower.includes('vocal')) defaultPan = 0      // Center
        else defaultPan = (Math.random() - 0.5) * 0.4  // Random slight pan
        pannersRef.current[stem].pan.value = defaultPan

        // 4. Reverb send (wet signal)
        reverbSendsRef.current[stem] = ctx.createGain()
        reverbSendsRef.current[stem].gain.value = 0  // Start with no reverb

        // Connect the chain
        gainsRef.current[stem].connect(compressorsRef.current[stem])
        compressorsRef.current[stem].connect(pannersRef.current[stem])

        // Split to dry (direct) and wet (reverb)
        pannersRef.current[stem].connect(masterGainRef.current)  // Dry signal
        pannersRef.current[stem].connect(reverbSendsRef.current[stem])  // Wet signal to reverb
        reverbSendsRef.current[stem].connect(convolverRef.current)  // Reverb convolver
      }
    }

    // Set initial reverb and pan values
    const newReverbs = {}
    const newPans = {}
    Object.keys(buffersRef.current).forEach(stem => {
      newReverbs[stem] = 0.15  // 15% reverb by default for studio sound
      newPans[stem] = pannersRef.current[stem]?.pan.value || 0
    })

    setDuration(maxDuration)
    setVolumes(newVolumes)
    setReverbs(newReverbs)
    setPans(newPans)
    setSolos({})
    setMutes({})
    setIsLoaded(true)
    pauseTimeRef.current = 0

    const totalEnd = performance.now()
    console.log('=== STEM LOADING COMPLETE ===')
    console.log(`Total loading time: ${(totalEnd - totalStart).toFixed(0)}ms`)
    console.log(`Duration: ${maxDuration.toFixed(1)}s`)
  }, [initAudio])

  // 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()

    // Disconnect existing audio graph nodes
    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 = {}
    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 β†’ 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.15
      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])

  // 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
  }, [])

  // Use refs to avoid stale closures in animation loop
  const isPlayingRef = useRef(false)
  const durationRef = useRef(0)

  // 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 elapsed = audioContextRef.current.currentTime - startTimeRef.current + 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) {
            // Loop: restart from region start (or song beginning)
            Object.values(sourcesRef.current).forEach(source => {
              try { source.stop() } catch (e) {}
            })
            sourcesRef.current = {}

            const loopStart = rawRegion ? rawRegion.start : 0
            const loopDur = rawRegion ? (rawRegion.end - rawRegion.start) : undefined

            pauseTimeRef.current = loopStart
            setCurrentTime(loopStart)

            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.connect(gainsRef.current[stem])
              sourcesRef.current[stem] = source
              source.start(0, loopStart, loopDur)
            })
            startTimeRef.current = ctx.currentTime
          } else {
            // Stop playback
            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.15
      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])

  // Play: starts all sources simultaneously
  const play = useCallback(async () => {
    if (!isLoaded || Object.keys(buffersRef.current).length === 0) {
      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 = {}

    // 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.connect(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])

  // Pause: suspends playback, saves position
  const pause = useCallback(() => {
    console.log('Pause called')
    isPlayingRef.current = false  // Update ref immediately
    // 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
    }

    setIsPlaying(false)
  }, [])

  // Stop: stops all sources, resets position
  const stop = useCallback(() => {
    Object.values(sourcesRef.current).forEach(source => {
      try { source.stop() } catch (e) {}
    })
    sourcesRef.current = {}

    pauseTimeRef.current = 0
    startTimeRef.current = 0
    setCurrentTime(0)
    setIsPlaying(false)
  }, [])

  // Seek: stop and restart from given position
  const seek = useCallback((time) => {
    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)

    // 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()
    }
  }, [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 = {}
    Object.keys(buffersRef.current).forEach(stem => {
      reset[stem] = 1
    })
    setVolumes(reset)
    setSolos({})
    setMutes({})
  }, [])

  return {
    // State
    isPlaying,
    isLoaded,
    isRawRegionActive,
    currentTime,
    duration,
    volumes,
    solos,
    mutes,
    reverbs,
    pans,
    analyserData,

    // Methods
    loadStems,
    loadStemsFromBytes,
    play,
    pause,
    stop,
    seek,
    setVolume,
    setSolo,
    setMute,
    setReverb,
    setPan,
    resetVolumes,
    setLoop,
    setRawRegion,
    clearBufferCache,
    getAnalyserData
  }
}