Mina Emadi commited on
Commit ·
35fde27
1
Parent(s): 6643ac9
some minor UI changes: adding the playback functionality for the chosen section with no key and bpm change and replacing the upload new with choose a different song
Browse files- .DS_Store +0 -0
- backend/.DS_Store +0 -0
- backend/models/__pycache__/session.cpython-310.pyc +0 -0
- backend/presets/sample-track/MIDI.mid +0 -0
- backend/presets/sample-track/bass.wav +3 -0
- backend/presets/sample-track/click_record.wav +3 -0
- backend/presets/sample-track/drums.wav +3 -0
- backend/presets/sample-track/guitar.wav +3 -0
- backend/presets/sample-track/synth.wav +3 -0
- backend/routers/__pycache__/detection.cpython-310.pyc +0 -0
- backend/services/__pycache__/midi_analyzer.cpython-310.pyc +0 -0
- convert_to_ogg.py +67 -0
- frontend/src/App.jsx +34 -4
- frontend/src/components/TransportBar.jsx +19 -3
- frontend/src/hooks/useAudioEngine.js +42 -10
.DS_Store
CHANGED
|
Binary files a/.DS_Store and b/.DS_Store differ
|
|
|
backend/.DS_Store
CHANGED
|
Binary files a/backend/.DS_Store and b/backend/.DS_Store differ
|
|
|
backend/models/__pycache__/session.cpython-310.pyc
CHANGED
|
Binary files a/backend/models/__pycache__/session.cpython-310.pyc and b/backend/models/__pycache__/session.cpython-310.pyc differ
|
|
|
backend/presets/sample-track/MIDI.mid
ADDED
|
Binary file (24.1 kB). View file
|
|
|
backend/presets/sample-track/bass.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:34d0bf1999390c1846ec635d7d13f719f12235dd0c3b43aff37c978defd20166
|
| 3 |
+
size 70893820
|
backend/presets/sample-track/click_record.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:436b475a472014349fe08f7acee5916bf17d1ff475471b6f04b45e1cf3f67233
|
| 3 |
+
size 70893820
|
backend/presets/sample-track/drums.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:536d1b91df21e93ca9f1f1352da5b70f64e6b0b8ac884e7ef794122f63183cfd
|
| 3 |
+
size 70893820
|
backend/presets/sample-track/guitar.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8ed112603fd68bed8a61c290b9ca36fcecee998b085eb283a21e127df0b2bc9e
|
| 3 |
+
size 70893820
|
backend/presets/sample-track/synth.wav
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ba8a34d85b65a69e09ba0a319136bba8c9d92a6e0fa10e9dc3770d6e26d25a49
|
| 3 |
+
size 70893820
|
backend/routers/__pycache__/detection.cpython-310.pyc
CHANGED
|
Binary files a/backend/routers/__pycache__/detection.cpython-310.pyc and b/backend/routers/__pycache__/detection.cpython-310.pyc differ
|
|
|
backend/services/__pycache__/midi_analyzer.cpython-310.pyc
CHANGED
|
Binary files a/backend/services/__pycache__/midi_analyzer.cpython-310.pyc and b/backend/services/__pycache__/midi_analyzer.cpython-310.pyc differ
|
|
|
convert_to_ogg.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Convert a single preset's WAV stems to OGG Vorbis at quality 9.
|
| 3 |
+
Verifies sample count matches original WAV after conversion.
|
| 4 |
+
Usage: python convert_to_ogg.py <preset_folder_name>
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import soundfile as sf
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
PRESETS_DIR = Path(__file__).parent / "backend" / "presets"
|
| 13 |
+
|
| 14 |
+
preset_name = sys.argv[1] if len(sys.argv) > 1 else "274-SUNSPILL"
|
| 15 |
+
preset_dir = PRESETS_DIR / preset_name
|
| 16 |
+
|
| 17 |
+
wav_files = sorted(preset_dir.glob("*.wav"))
|
| 18 |
+
if not wav_files:
|
| 19 |
+
print(f"No WAV files found in {preset_dir}")
|
| 20 |
+
sys.exit(1)
|
| 21 |
+
|
| 22 |
+
print(f"Converting {preset_name} ({len(wav_files)} stems) at OGG quality 9\n")
|
| 23 |
+
|
| 24 |
+
all_ok = True
|
| 25 |
+
for wav_path in wav_files:
|
| 26 |
+
ogg_path = wav_path.with_suffix(".ogg")
|
| 27 |
+
|
| 28 |
+
# Get original sample count
|
| 29 |
+
info = sf.info(str(wav_path))
|
| 30 |
+
original_frames = info.frames
|
| 31 |
+
sr = info.samplerate
|
| 32 |
+
|
| 33 |
+
# Convert: pipe exact sample count via atrim so encoder sees correct length
|
| 34 |
+
result = subprocess.run(
|
| 35 |
+
[
|
| 36 |
+
"ffmpeg", "-y", "-i", str(wav_path),
|
| 37 |
+
"-af", f"atrim=end_sample={original_frames}",
|
| 38 |
+
"-c:a", "vorbis", "-strict", "experimental",
|
| 39 |
+
"-q:a", "9",
|
| 40 |
+
str(ogg_path)
|
| 41 |
+
],
|
| 42 |
+
capture_output=True
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
if result.returncode != 0:
|
| 46 |
+
print(f" ERROR converting {wav_path.name}")
|
| 47 |
+
print(result.stderr.decode()[-300:])
|
| 48 |
+
all_ok = False
|
| 49 |
+
continue
|
| 50 |
+
|
| 51 |
+
# Verify decoded length
|
| 52 |
+
ogg_info = sf.info(str(ogg_path))
|
| 53 |
+
decoded_frames = ogg_info.frames
|
| 54 |
+
delta = decoded_frames - original_frames
|
| 55 |
+
|
| 56 |
+
wav_mb = wav_path.stat().st_size / 1024 / 1024
|
| 57 |
+
ogg_mb = ogg_path.stat().st_size / 1024 / 1024
|
| 58 |
+
length_ok = "✓" if delta == 0 else f"✗ delta={delta:+d} samples ({delta/sr*1000:.1f}ms)"
|
| 59 |
+
|
| 60 |
+
print(f" {wav_path.name}")
|
| 61 |
+
print(f" Size: {wav_mb:.1f} MB → {ogg_mb:.1f} MB ({ogg_mb/wav_mb*100:.0f}%)")
|
| 62 |
+
print(f" Length: {original_frames} → {decoded_frames} samples {length_ok}")
|
| 63 |
+
|
| 64 |
+
if all_ok:
|
| 65 |
+
print("\nAll conversions successful. WAV files kept for now — delete manually after listening test.")
|
| 66 |
+
else:
|
| 67 |
+
print("\nSome conversions failed.")
|
frontend/src/App.jsx
CHANGED
|
@@ -51,6 +51,8 @@ function App() {
|
|
| 51 |
setPan,
|
| 52 |
resetVolumes,
|
| 53 |
setLoop,
|
|
|
|
|
|
|
| 54 |
clearBufferCache
|
| 55 |
} = useAudioEngine()
|
| 56 |
|
|
@@ -84,6 +86,24 @@ function App() {
|
|
| 84 |
await loadPreset(presetName)
|
| 85 |
}, [loadPreset])
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
const handleProcess = useCallback(async (semitones, targetBpm) => {
|
| 88 |
setIsProcessing(true)
|
| 89 |
resetProgress()
|
|
@@ -100,6 +120,7 @@ function App() {
|
|
| 100 |
// Clear stale region cache (new processing produced new audio)
|
| 101 |
clearBufferCache('region')
|
| 102 |
await loadStems(sessionId, stems, { region: true })
|
|
|
|
| 103 |
setPlaybackMode('region')
|
| 104 |
setLoop(true)
|
| 105 |
} else {
|
|
@@ -113,7 +134,7 @@ function App() {
|
|
| 113 |
} finally {
|
| 114 |
setIsProcessing(false)
|
| 115 |
}
|
| 116 |
-
}, [process, sessionId, stems, loadStems, resetProgress, hasRegion, regionStart, regionEnd, setLoop, clearBufferCache])
|
| 117 |
|
| 118 |
const handleGenerate = useCallback(async (prompt) => {
|
| 119 |
if (!hasRegion) return
|
|
@@ -134,6 +155,7 @@ function App() {
|
|
| 134 |
const handlePlayFullSong = useCallback(async () => {
|
| 135 |
// Stop current playback
|
| 136 |
stop()
|
|
|
|
| 137 |
setLoop(false)
|
| 138 |
setPlaybackMode('full')
|
| 139 |
|
|
@@ -142,7 +164,7 @@ function App() {
|
|
| 142 |
// Setting processed=true will get processed if available, else original
|
| 143 |
await loadStems(sessionId, stems)
|
| 144 |
}
|
| 145 |
-
}, [stop, setLoop, sessionId, stems, loadStems])
|
| 146 |
|
| 147 |
const handleStemsReady = useCallback(async () => {
|
| 148 |
if (sessionId && stems.length > 0) {
|
|
@@ -210,7 +232,7 @@ function App() {
|
|
| 210 |
onClick={() => window.location.reload()}
|
| 211 |
className="text-sm text-gray-400 hover:text-white transition-colors"
|
| 212 |
>
|
| 213 |
-
|
| 214 |
</button>
|
| 215 |
</header>
|
| 216 |
|
|
@@ -246,7 +268,9 @@ function App() {
|
|
| 246 |
isPlaying={isPlaying}
|
| 247 |
currentTime={currentTime}
|
| 248 |
duration={duration}
|
| 249 |
-
onPlay={
|
|
|
|
|
|
|
| 250 |
onPause={pause}
|
| 251 |
onStop={stop}
|
| 252 |
onSeek={seek}
|
|
@@ -255,10 +279,16 @@ function App() {
|
|
| 255 |
onRegionChange={(start, end) => {
|
| 256 |
setRegionStart(start)
|
| 257 |
setRegionEnd(end)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
}}
|
| 259 |
onClearRegion={() => {
|
| 260 |
setRegionStart(null)
|
| 261 |
setRegionEnd(null)
|
|
|
|
|
|
|
| 262 |
if (playbackMode === 'region') {
|
| 263 |
handlePlayFullSong()
|
| 264 |
}
|
|
|
|
| 51 |
setPan,
|
| 52 |
resetVolumes,
|
| 53 |
setLoop,
|
| 54 |
+
setRawRegion,
|
| 55 |
+
isRawRegionActive,
|
| 56 |
clearBufferCache
|
| 57 |
} = useAudioEngine()
|
| 58 |
|
|
|
|
| 86 |
await loadPreset(presetName)
|
| 87 |
}, [loadPreset])
|
| 88 |
|
| 89 |
+
// Main play button: always plays full song — clears any active raw-region loop first
|
| 90 |
+
const handlePlay = useCallback(() => {
|
| 91 |
+
if (isRawRegionActive) {
|
| 92 |
+
setRawRegion(null)
|
| 93 |
+
setLoop(false)
|
| 94 |
+
}
|
| 95 |
+
play()
|
| 96 |
+
}, [isRawRegionActive, setRawRegion, setLoop, play])
|
| 97 |
+
|
| 98 |
+
// Play Section: loops the selected region using full-song buffers (no processing needed)
|
| 99 |
+
const handlePlaySection = useCallback(() => {
|
| 100 |
+
if (!hasRegion) return
|
| 101 |
+
stop()
|
| 102 |
+
setRawRegion(regionStart, regionEnd)
|
| 103 |
+
setLoop(true)
|
| 104 |
+
play()
|
| 105 |
+
}, [hasRegion, stop, setRawRegion, regionStart, regionEnd, setLoop, play])
|
| 106 |
+
|
| 107 |
const handleProcess = useCallback(async (semitones, targetBpm) => {
|
| 108 |
setIsProcessing(true)
|
| 109 |
resetProgress()
|
|
|
|
| 120 |
// Clear stale region cache (new processing produced new audio)
|
| 121 |
clearBufferCache('region')
|
| 122 |
await loadStems(sessionId, stems, { region: true })
|
| 123 |
+
setRawRegion(null) // processed region takes over; raw region no longer needed
|
| 124 |
setPlaybackMode('region')
|
| 125 |
setLoop(true)
|
| 126 |
} else {
|
|
|
|
| 134 |
} finally {
|
| 135 |
setIsProcessing(false)
|
| 136 |
}
|
| 137 |
+
}, [process, sessionId, stems, loadStems, resetProgress, hasRegion, regionStart, regionEnd, setRawRegion, setLoop, clearBufferCache])
|
| 138 |
|
| 139 |
const handleGenerate = useCallback(async (prompt) => {
|
| 140 |
if (!hasRegion) return
|
|
|
|
| 155 |
const handlePlayFullSong = useCallback(async () => {
|
| 156 |
// Stop current playback
|
| 157 |
stop()
|
| 158 |
+
setRawRegion(null)
|
| 159 |
setLoop(false)
|
| 160 |
setPlaybackMode('full')
|
| 161 |
|
|
|
|
| 164 |
// Setting processed=true will get processed if available, else original
|
| 165 |
await loadStems(sessionId, stems)
|
| 166 |
}
|
| 167 |
+
}, [stop, setRawRegion, setLoop, sessionId, stems, loadStems])
|
| 168 |
|
| 169 |
const handleStemsReady = useCallback(async () => {
|
| 170 |
if (sessionId && stems.length > 0) {
|
|
|
|
| 232 |
onClick={() => window.location.reload()}
|
| 233 |
className="text-sm text-gray-400 hover:text-white transition-colors"
|
| 234 |
>
|
| 235 |
+
Choose a different song
|
| 236 |
</button>
|
| 237 |
</header>
|
| 238 |
|
|
|
|
| 268 |
isPlaying={isPlaying}
|
| 269 |
currentTime={currentTime}
|
| 270 |
duration={duration}
|
| 271 |
+
onPlay={handlePlay}
|
| 272 |
+
onPlaySection={handlePlaySection}
|
| 273 |
+
isRawRegionActive={isRawRegionActive}
|
| 274 |
onPause={pause}
|
| 275 |
onStop={stop}
|
| 276 |
onSeek={seek}
|
|
|
|
| 279 |
onRegionChange={(start, end) => {
|
| 280 |
setRegionStart(start)
|
| 281 |
setRegionEnd(end)
|
| 282 |
+
if (playbackMode !== 'region') {
|
| 283 |
+
setRawRegion(start, end)
|
| 284 |
+
setLoop(true)
|
| 285 |
+
}
|
| 286 |
}}
|
| 287 |
onClearRegion={() => {
|
| 288 |
setRegionStart(null)
|
| 289 |
setRegionEnd(null)
|
| 290 |
+
setRawRegion(null)
|
| 291 |
+
setLoop(false)
|
| 292 |
if (playbackMode === 'region') {
|
| 293 |
handlePlayFullSong()
|
| 294 |
}
|
frontend/src/components/TransportBar.jsx
CHANGED
|
@@ -42,6 +42,8 @@ function TransportBar({
|
|
| 42 |
onClearRegion,
|
| 43 |
playbackMode,
|
| 44 |
onPlayFullSong,
|
|
|
|
|
|
|
| 45 |
fullSongDuration
|
| 46 |
}) {
|
| 47 |
// In region mode, map the playhead position into the region's span on the full bar
|
|
@@ -264,6 +266,7 @@ function TransportBar({
|
|
| 264 |
</svg>
|
| 265 |
</button>
|
| 266 |
|
|
|
|
| 267 |
{/* Progress bar with region selection */}
|
| 268 |
<div className="flex-1 flex items-center gap-3">
|
| 269 |
<span className="text-sm text-gray-400 w-12 text-right">
|
|
@@ -379,6 +382,19 @@ function TransportBar({
|
|
| 379 |
</span>
|
| 380 |
</div>
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
{/* Clear region button */}
|
| 383 |
<button
|
| 384 |
onClick={onClearRegion}
|
|
@@ -387,8 +403,8 @@ function TransportBar({
|
|
| 387 |
Clear Selection
|
| 388 |
</button>
|
| 389 |
|
| 390 |
-
{/* Looping indicator + Play Full Song button
|
| 391 |
-
{playbackMode === 'region' && (
|
| 392 |
<>
|
| 393 |
<span className="text-xs text-yellow-400 flex items-center gap-1">
|
| 394 |
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
@@ -397,7 +413,7 @@ function TransportBar({
|
|
| 397 |
<path d="M7 22l-4-4 4-4" />
|
| 398 |
<path d="M21 13v2a4 4 0 01-4 4H3" />
|
| 399 |
</svg>
|
| 400 |
-
Looping
|
| 401 |
</span>
|
| 402 |
|
| 403 |
<button
|
|
|
|
| 42 |
onClearRegion,
|
| 43 |
playbackMode,
|
| 44 |
onPlayFullSong,
|
| 45 |
+
onPlaySection,
|
| 46 |
+
isRawRegionActive,
|
| 47 |
fullSongDuration
|
| 48 |
}) {
|
| 49 |
// In region mode, map the playhead position into the region's span on the full bar
|
|
|
|
| 266 |
</svg>
|
| 267 |
</button>
|
| 268 |
|
| 269 |
+
|
| 270 |
{/* Progress bar with region selection */}
|
| 271 |
<div className="flex-1 flex items-center gap-3">
|
| 272 |
<span className="text-sm text-gray-400 w-12 text-right">
|
|
|
|
| 382 |
</span>
|
| 383 |
</div>
|
| 384 |
|
| 385 |
+
{/* Play Section button */}
|
| 386 |
+
{playbackMode !== 'region' && (
|
| 387 |
+
<button
|
| 388 |
+
onClick={onPlaySection}
|
| 389 |
+
className="flex items-center gap-1.5 px-3 py-1 rounded-lg bg-yellow-500/20 hover:bg-yellow-500/30 border border-yellow-500/40 text-yellow-300 text-xs font-medium transition-colors"
|
| 390 |
+
>
|
| 391 |
+
<svg className="w-3 h-3 fill-current" viewBox="0 0 24 24">
|
| 392 |
+
<polygon points="5,3 19,12 5,21" />
|
| 393 |
+
</svg>
|
| 394 |
+
Play Section
|
| 395 |
+
</button>
|
| 396 |
+
)}
|
| 397 |
+
|
| 398 |
{/* Clear region button */}
|
| 399 |
<button
|
| 400 |
onClick={onClearRegion}
|
|
|
|
| 403 |
Clear Selection
|
| 404 |
</button>
|
| 405 |
|
| 406 |
+
{/* Looping indicator + Play Full Song button — shown when section is looping */}
|
| 407 |
+
{(playbackMode === 'region' || isRawRegionActive) && (
|
| 408 |
<>
|
| 409 |
<span className="text-xs text-yellow-400 flex items-center gap-1">
|
| 410 |
<svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
|
|
| 413 |
<path d="M7 22l-4-4 4-4" />
|
| 414 |
<path d="M21 13v2a4 4 0 01-4 4H3" />
|
| 415 |
</svg>
|
| 416 |
+
Looping section
|
| 417 |
</span>
|
| 418 |
|
| 419 |
<button
|
frontend/src/hooks/useAudioEngine.js
CHANGED
|
@@ -40,6 +40,8 @@ export function useAudioEngine() {
|
|
| 40 |
const animationRef = useRef(null)
|
| 41 |
const lastTimeUpdateRef = useRef(0) // Last time we updated currentTime state (for throttling)
|
| 42 |
const loopRef = useRef(false) // Whether to loop playback
|
|
|
|
|
|
|
| 43 |
// Persistent AudioBuffer cache: keys like "drums_full", "drums_region" → AudioBuffer
|
| 44 |
// Survives across loadStems calls so we skip fetch+decode on replay
|
| 45 |
const bufferCacheRef = useRef({})
|
|
@@ -385,8 +387,10 @@ export function useAudioEngine() {
|
|
| 385 |
// Update current time - throttled to every 100ms for performance
|
| 386 |
if (audioContextRef.current && isPlayingRef.current) {
|
| 387 |
const elapsed = audioContextRef.current.currentTime - startTimeRef.current + pauseTimeRef.current
|
| 388 |
-
const
|
| 389 |
-
|
|
|
|
|
|
|
| 390 |
|
| 391 |
// Only update state every 100ms (10fps) to reduce re-renders
|
| 392 |
const timeSinceLastUpdate = timestamp - lastTimeUpdateRef.current
|
|
@@ -396,17 +400,20 @@ export function useAudioEngine() {
|
|
| 396 |
}
|
| 397 |
|
| 398 |
// Check if playback ended
|
| 399 |
-
if (elapsed >=
|
| 400 |
if (loopRef.current) {
|
| 401 |
-
// Loop: restart from beginning
|
| 402 |
Object.values(sourcesRef.current).forEach(source => {
|
| 403 |
try { source.stop() } catch (e) {}
|
| 404 |
})
|
| 405 |
sourcesRef.current = {}
|
| 406 |
-
pauseTimeRef.current = 0
|
| 407 |
-
setCurrentTime(0)
|
| 408 |
|
| 409 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
const ctx = audioContextRef.current
|
| 411 |
Object.entries(buffersRef.current).forEach(([stem, buffer]) => {
|
| 412 |
if (!buffer || !gainsRef.current[stem]) return
|
|
@@ -414,7 +421,7 @@ export function useAudioEngine() {
|
|
| 414 |
source.buffer = buffer
|
| 415 |
source.connect(gainsRef.current[stem])
|
| 416 |
sourcesRef.current[stem] = source
|
| 417 |
-
source.start(0,
|
| 418 |
})
|
| 419 |
startTimeRef.current = ctx.currentTime
|
| 420 |
} else {
|
|
@@ -509,6 +516,7 @@ export function useAudioEngine() {
|
|
| 509 |
sourcesRef.current = {}
|
| 510 |
|
| 511 |
// Create new source nodes for each stem (AudioBufferSourceNode is one-shot)
|
|
|
|
| 512 |
Object.entries(buffersRef.current).forEach(([stem, buffer]) => {
|
| 513 |
if (!buffer || !gainsRef.current[stem]) return
|
| 514 |
|
|
@@ -517,8 +525,13 @@ export function useAudioEngine() {
|
|
| 517 |
source.connect(gainsRef.current[stem])
|
| 518 |
sourcesRef.current[stem] = source
|
| 519 |
|
| 520 |
-
|
| 521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
})
|
| 523 |
|
| 524 |
startTimeRef.current = ctx.currentTime
|
|
@@ -611,6 +624,23 @@ export function useAudioEngine() {
|
|
| 611 |
loopRef.current = enabled
|
| 612 |
}, [])
|
| 613 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
// Clear cached AudioBuffers for a specific tag ('full', 'region', or all)
|
| 615 |
const clearBufferCache = useCallback((tag = null) => {
|
| 616 |
if (tag) {
|
|
@@ -642,6 +672,7 @@ export function useAudioEngine() {
|
|
| 642 |
// State
|
| 643 |
isPlaying,
|
| 644 |
isLoaded,
|
|
|
|
| 645 |
currentTime,
|
| 646 |
duration,
|
| 647 |
volumes,
|
|
@@ -665,6 +696,7 @@ export function useAudioEngine() {
|
|
| 665 |
setPan,
|
| 666 |
resetVolumes,
|
| 667 |
setLoop,
|
|
|
|
| 668 |
clearBufferCache,
|
| 669 |
getAnalyserData
|
| 670 |
}
|
|
|
|
| 40 |
const animationRef = useRef(null)
|
| 41 |
const lastTimeUpdateRef = useRef(0) // Last time we updated currentTime state (for throttling)
|
| 42 |
const loopRef = useRef(false) // Whether to loop playback
|
| 43 |
+
const rawRegionRef = useRef(null) // { start, end } in seconds for raw-region loop (no processing)
|
| 44 |
+
const [isRawRegionActive, setIsRawRegionActive] = useState(false)
|
| 45 |
// Persistent AudioBuffer cache: keys like "drums_full", "drums_region" → AudioBuffer
|
| 46 |
// Survives across loadStems calls so we skip fetch+decode on replay
|
| 47 |
const bufferCacheRef = useRef({})
|
|
|
|
| 387 |
// Update current time - throttled to every 100ms for performance
|
| 388 |
if (audioContextRef.current && isPlayingRef.current) {
|
| 389 |
const elapsed = audioContextRef.current.currentTime - startTimeRef.current + pauseTimeRef.current
|
| 390 |
+
const rawRegion = rawRegionRef.current
|
| 391 |
+
// For raw region loops, treat region end as the effective song end
|
| 392 |
+
const effectiveDur = rawRegion ? rawRegion.end : durationRef.current
|
| 393 |
+
const newTime = Math.min(elapsed, effectiveDur)
|
| 394 |
|
| 395 |
// Only update state every 100ms (10fps) to reduce re-renders
|
| 396 |
const timeSinceLastUpdate = timestamp - lastTimeUpdateRef.current
|
|
|
|
| 400 |
}
|
| 401 |
|
| 402 |
// Check if playback ended
|
| 403 |
+
if (elapsed >= effectiveDur && effectiveDur > 0) {
|
| 404 |
if (loopRef.current) {
|
| 405 |
+
// Loop: restart from region start (or song beginning)
|
| 406 |
Object.values(sourcesRef.current).forEach(source => {
|
| 407 |
try { source.stop() } catch (e) {}
|
| 408 |
})
|
| 409 |
sourcesRef.current = {}
|
|
|
|
|
|
|
| 410 |
|
| 411 |
+
const loopStart = rawRegion ? rawRegion.start : 0
|
| 412 |
+
const loopDur = rawRegion ? (rawRegion.end - rawRegion.start) : undefined
|
| 413 |
+
|
| 414 |
+
pauseTimeRef.current = loopStart
|
| 415 |
+
setCurrentTime(loopStart)
|
| 416 |
+
|
| 417 |
const ctx = audioContextRef.current
|
| 418 |
Object.entries(buffersRef.current).forEach(([stem, buffer]) => {
|
| 419 |
if (!buffer || !gainsRef.current[stem]) return
|
|
|
|
| 421 |
source.buffer = buffer
|
| 422 |
source.connect(gainsRef.current[stem])
|
| 423 |
sourcesRef.current[stem] = source
|
| 424 |
+
source.start(0, loopStart, loopDur)
|
| 425 |
})
|
| 426 |
startTimeRef.current = ctx.currentTime
|
| 427 |
} else {
|
|
|
|
| 516 |
sourcesRef.current = {}
|
| 517 |
|
| 518 |
// Create new source nodes for each stem (AudioBufferSourceNode is one-shot)
|
| 519 |
+
const rawRegion = rawRegionRef.current
|
| 520 |
Object.entries(buffersRef.current).forEach(([stem, buffer]) => {
|
| 521 |
if (!buffer || !gainsRef.current[stem]) return
|
| 522 |
|
|
|
|
| 525 |
source.connect(gainsRef.current[stem])
|
| 526 |
sourcesRef.current[stem] = source
|
| 527 |
|
| 528 |
+
if (rawRegion) {
|
| 529 |
+
// Confine playback to the selected region
|
| 530 |
+
const remaining = Math.max(0, rawRegion.end - pauseTimeRef.current)
|
| 531 |
+
source.start(0, pauseTimeRef.current, remaining)
|
| 532 |
+
} else {
|
| 533 |
+
source.start(0, pauseTimeRef.current)
|
| 534 |
+
}
|
| 535 |
})
|
| 536 |
|
| 537 |
startTimeRef.current = ctx.currentTime
|
|
|
|
| 624 |
loopRef.current = enabled
|
| 625 |
}, [])
|
| 626 |
|
| 627 |
+
// Set raw region for looping a selection without processing.
|
| 628 |
+
// When active, play() confines audio to [start, end] and loops there.
|
| 629 |
+
const setRawRegion = useCallback((start, end) => {
|
| 630 |
+
if (start !== null && end !== null) {
|
| 631 |
+
rawRegionRef.current = { start, end }
|
| 632 |
+
setIsRawRegionActive(true)
|
| 633 |
+
// Snap playhead to region start if it's currently outside the region
|
| 634 |
+
if (pauseTimeRef.current < start || pauseTimeRef.current >= end) {
|
| 635 |
+
pauseTimeRef.current = start
|
| 636 |
+
setCurrentTime(start)
|
| 637 |
+
}
|
| 638 |
+
} else {
|
| 639 |
+
rawRegionRef.current = null
|
| 640 |
+
setIsRawRegionActive(false)
|
| 641 |
+
}
|
| 642 |
+
}, [])
|
| 643 |
+
|
| 644 |
// Clear cached AudioBuffers for a specific tag ('full', 'region', or all)
|
| 645 |
const clearBufferCache = useCallback((tag = null) => {
|
| 646 |
if (tag) {
|
|
|
|
| 672 |
// State
|
| 673 |
isPlaying,
|
| 674 |
isLoaded,
|
| 675 |
+
isRawRegionActive,
|
| 676 |
currentTime,
|
| 677 |
duration,
|
| 678 |
volumes,
|
|
|
|
| 696 |
setPan,
|
| 697 |
resetVolumes,
|
| 698 |
setLoop,
|
| 699 |
+
setRawRegion,
|
| 700 |
clearBufferCache,
|
| 701 |
getAnalyserData
|
| 702 |
}
|