Spaces:
Sleeping
Sleeping
Mina Emadi commited on
Commit ·
3ef0232
1
Parent(s): b384007
deleted the server and client side decoding so the play back is ready instantly after loading the stems
Browse files- backend/routers/__pycache__/stems.cpython-310.pyc +0 -0
- backend/routers/stems.py +28 -2
- backend/services/music_generator.py +7 -0
- frontend/src/.backup/App.jsx.bak +305 -0
- frontend/src/.backup/ControlPanel.jsx.bak +349 -0
- frontend/src/.backup/useSession.js.bak +201 -0
- frontend/src/App.jsx +23 -0
- frontend/src/components/ControlPanel.jsx +152 -2
- frontend/src/hooks/useAudioEngine.js +14 -8
- frontend/src/hooks/useSession.js +39 -0
backend/routers/__pycache__/stems.cpython-310.pyc
CHANGED
|
Binary files a/backend/routers/__pycache__/stems.cpython-310.pyc and b/backend/routers/__pycache__/stems.cpython-310.pyc differ
|
|
|
backend/routers/stems.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 1 |
"""Stems router for retrieving processed audio."""
|
| 2 |
|
| 3 |
import time
|
|
|
|
| 4 |
from fastapi import APIRouter, HTTPException
|
| 5 |
-
from fastapi.responses import StreamingResponse
|
| 6 |
import io
|
| 7 |
|
| 8 |
from ..models.session import get_session
|
|
@@ -90,6 +91,29 @@ async def get_stem(
|
|
| 90 |
detail=f"Stem '{stem_name}' not found"
|
| 91 |
)
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
start_time = time.time()
|
| 94 |
|
| 95 |
# Create cache key based on stem name, processed flag, and region flag
|
|
@@ -125,6 +149,8 @@ async def get_stem(
|
|
| 125 |
io.BytesIO(audio_bytes),
|
| 126 |
media_type=media_type,
|
| 127 |
headers={
|
| 128 |
-
"Content-Disposition": f'
|
|
|
|
|
|
|
| 129 |
}
|
| 130 |
)
|
|
|
|
| 1 |
"""Stems router for retrieving processed audio."""
|
| 2 |
|
| 3 |
import time
|
| 4 |
+
import numpy as np
|
| 5 |
from fastapi import APIRouter, HTTPException
|
| 6 |
+
from fastapi.responses import StreamingResponse, Response
|
| 7 |
import io
|
| 8 |
|
| 9 |
from ..models.session import get_session
|
|
|
|
| 91 |
detail=f"Stem '{stem_name}' not found"
|
| 92 |
)
|
| 93 |
|
| 94 |
+
# Serve raw float32 PCM — skip WAV encode/decode entirely
|
| 95 |
+
if format == "pcm":
|
| 96 |
+
audio = stem.audio
|
| 97 |
+
if audio.ndim == 2:
|
| 98 |
+
audio = np.mean(audio, axis=1).astype(np.float32)
|
| 99 |
+
else:
|
| 100 |
+
audio = np.ascontiguousarray(audio, dtype=np.float32)
|
| 101 |
+
num_frames = len(audio)
|
| 102 |
+
pcm_bytes = audio.tobytes()
|
| 103 |
+
print(f"[{stem_name}] Serving {len(pcm_bytes)/1024/1024:.1f}MB raw PCM ({num_frames} frames @ {stem.sample_rate}Hz)")
|
| 104 |
+
return Response(
|
| 105 |
+
content=pcm_bytes,
|
| 106 |
+
media_type="application/octet-stream",
|
| 107 |
+
headers={
|
| 108 |
+
"Content-Disposition": f'inline; filename="{stem_name}.pcm"',
|
| 109 |
+
"Content-Length": str(len(pcm_bytes)),
|
| 110 |
+
"X-Sample-Rate": str(stem.sample_rate),
|
| 111 |
+
"X-Channels": "1",
|
| 112 |
+
"X-Frames": str(num_frames),
|
| 113 |
+
"Access-Control-Expose-Headers": "X-Sample-Rate, X-Channels, X-Frames",
|
| 114 |
+
}
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
start_time = time.time()
|
| 118 |
|
| 119 |
# Create cache key based on stem name, processed flag, and region flag
|
|
|
|
| 149 |
io.BytesIO(audio_bytes),
|
| 150 |
media_type=media_type,
|
| 151 |
headers={
|
| 152 |
+
"Content-Disposition": f'inline; filename="{stem_name}.wav"',
|
| 153 |
+
"Content-Length": str(len(audio_bytes)),
|
| 154 |
+
"Accept-Ranges": "bytes"
|
| 155 |
}
|
| 156 |
)
|
backend/services/music_generator.py
CHANGED
|
@@ -63,6 +63,13 @@ def generate_continuation(audio_np, sr, text_prompt, duration=15.0):
|
|
| 63 |
model, processor = _load_model()
|
| 64 |
device = _model_cache["device"]
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
# Use the last 4 seconds of input as conditioning audio (reduces repetition)
|
| 67 |
max_conditioning = 4.0
|
| 68 |
max_samples = int(max_conditioning * sr)
|
|
|
|
| 63 |
model, processor = _load_model()
|
| 64 |
device = _model_cache["device"]
|
| 65 |
|
| 66 |
+
# MusicGen expects audio at 32000 Hz — resample if needed
|
| 67 |
+
model_sr = model.config.audio_encoder.sampling_rate # 32000
|
| 68 |
+
if sr != model_sr:
|
| 69 |
+
import librosa
|
| 70 |
+
audio_np = librosa.resample(audio_np, orig_sr=sr, target_sr=model_sr)
|
| 71 |
+
sr = model_sr
|
| 72 |
+
|
| 73 |
# Use the last 4 seconds of input as conditioning audio (reduces repetition)
|
| 74 |
max_conditioning = 4.0
|
| 75 |
max_samples = int(max_conditioning * sr)
|
frontend/src/.backup/App.jsx.bak
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useCallback, useEffect } from 'react'
|
| 2 |
+
import FileUpload from './components/FileUpload'
|
| 3 |
+
import AnalysisDisplay from './components/AnalysisDisplay'
|
| 4 |
+
import ControlPanel from './components/ControlPanel'
|
| 5 |
+
import StemMixer from './components/StemMixer'
|
| 6 |
+
import TransportBar from './components/TransportBar'
|
| 7 |
+
import Waveform from './components/Waveform'
|
| 8 |
+
import ProcessingOverlay from './components/ProcessingOverlay'
|
| 9 |
+
import { useSession } from './hooks/useSession'
|
| 10 |
+
import { useAudioEngine } from './hooks/useAudioEngine'
|
| 11 |
+
import { useProcessingProgress } from './hooks/useProcessingProgress'
|
| 12 |
+
|
| 13 |
+
function App() {
|
| 14 |
+
const {
|
| 15 |
+
sessionId,
|
| 16 |
+
stems,
|
| 17 |
+
detection,
|
| 18 |
+
loading,
|
| 19 |
+
error,
|
| 20 |
+
upload,
|
| 21 |
+
detect,
|
| 22 |
+
process,
|
| 23 |
+
generate,
|
| 24 |
+
clearError
|
| 25 |
+
} = useSession()
|
| 26 |
+
|
| 27 |
+
const {
|
| 28 |
+
isPlaying,
|
| 29 |
+
isLoaded,
|
| 30 |
+
currentTime,
|
| 31 |
+
duration,
|
| 32 |
+
volumes,
|
| 33 |
+
solos,
|
| 34 |
+
mutes,
|
| 35 |
+
reverbs,
|
| 36 |
+
pans,
|
| 37 |
+
analyserData,
|
| 38 |
+
loadStems,
|
| 39 |
+
play,
|
| 40 |
+
pause,
|
| 41 |
+
stop,
|
| 42 |
+
seek,
|
| 43 |
+
setVolume,
|
| 44 |
+
setSolo,
|
| 45 |
+
setMute,
|
| 46 |
+
setReverb,
|
| 47 |
+
setPan,
|
| 48 |
+
resetVolumes,
|
| 49 |
+
setLoop,
|
| 50 |
+
clearBufferCache
|
| 51 |
+
} = useAudioEngine()
|
| 52 |
+
|
| 53 |
+
const [isProcessing, setIsProcessing] = useState(false)
|
| 54 |
+
const [isGenerating, setIsGenerating] = useState(false)
|
| 55 |
+
const [continuationReady, setContinuationReady] = useState(false)
|
| 56 |
+
const { progress: processingProgress, resetProgress } = useProcessingProgress(sessionId)
|
| 57 |
+
|
| 58 |
+
// Region selection state
|
| 59 |
+
const [regionStart, setRegionStart] = useState(null)
|
| 60 |
+
const [regionEnd, setRegionEnd] = useState(null)
|
| 61 |
+
// 'full' = playing full song stems, 'region' = playing processed region slice
|
| 62 |
+
const [playbackMode, setPlaybackMode] = useState('full')
|
| 63 |
+
// Store the full-song duration so we can show the region on the full-length bar
|
| 64 |
+
const [fullSongDuration, setFullSongDuration] = useState(0)
|
| 65 |
+
|
| 66 |
+
const hasRegion = regionStart !== null && regionEnd !== null
|
| 67 |
+
|
| 68 |
+
const handleUpload = useCallback(async (files) => {
|
| 69 |
+
await upload(files)
|
| 70 |
+
}, [upload])
|
| 71 |
+
|
| 72 |
+
const handleProcess = useCallback(async (semitones, targetBpm) => {
|
| 73 |
+
setIsProcessing(true)
|
| 74 |
+
resetProgress()
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const result = await process(
|
| 78 |
+
semitones,
|
| 79 |
+
targetBpm,
|
| 80 |
+
hasRegion ? regionStart : null,
|
| 81 |
+
hasRegion ? regionEnd : null
|
| 82 |
+
)
|
| 83 |
+
if (result?.success && sessionId && stems.length > 0) {
|
| 84 |
+
if (hasRegion) {
|
| 85 |
+
// Clear stale region cache (new processing produced new audio)
|
| 86 |
+
clearBufferCache('region')
|
| 87 |
+
await loadStems(sessionId, stems, { region: true })
|
| 88 |
+
setPlaybackMode('region')
|
| 89 |
+
setLoop(true)
|
| 90 |
+
} else {
|
| 91 |
+
// Clear stale full cache (new processing produced new audio)
|
| 92 |
+
clearBufferCache('full')
|
| 93 |
+
await loadStems(sessionId, stems)
|
| 94 |
+
setPlaybackMode('full')
|
| 95 |
+
setLoop(false)
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
} finally {
|
| 99 |
+
setIsProcessing(false)
|
| 100 |
+
}
|
| 101 |
+
}, [process, sessionId, stems, loadStems, resetProgress, hasRegion, regionStart, regionEnd, setLoop, clearBufferCache])
|
| 102 |
+
|
| 103 |
+
const handleGenerate = useCallback(async (prompt) => {
|
| 104 |
+
if (!hasRegion) return
|
| 105 |
+
setIsGenerating(true)
|
| 106 |
+
setContinuationReady(false)
|
| 107 |
+
resetProgress()
|
| 108 |
+
|
| 109 |
+
try {
|
| 110 |
+
const result = await generate(regionStart, regionEnd, 15.0, prompt || null)
|
| 111 |
+
if (result?.success) {
|
| 112 |
+
setContinuationReady(true)
|
| 113 |
+
}
|
| 114 |
+
} finally {
|
| 115 |
+
setIsGenerating(false)
|
| 116 |
+
}
|
| 117 |
+
}, [generate, hasRegion, regionStart, regionEnd, resetProgress])
|
| 118 |
+
|
| 119 |
+
const handlePlayFullSong = useCallback(async () => {
|
| 120 |
+
// Stop current playback
|
| 121 |
+
stop()
|
| 122 |
+
setLoop(false)
|
| 123 |
+
setPlaybackMode('full')
|
| 124 |
+
|
| 125 |
+
if (sessionId && stems.length > 0) {
|
| 126 |
+
// Reload full stems (original or previously full-processed)
|
| 127 |
+
// Setting processed=true will get processed if available, else original
|
| 128 |
+
await loadStems(sessionId, stems)
|
| 129 |
+
}
|
| 130 |
+
}, [stop, setLoop, sessionId, stems, loadStems])
|
| 131 |
+
|
| 132 |
+
const handleStemsReady = useCallback(async () => {
|
| 133 |
+
if (sessionId && stems.length > 0) {
|
| 134 |
+
console.log('=== handleStemsReady called ===')
|
| 135 |
+
console.log('Session:', sessionId)
|
| 136 |
+
console.log('Stems to load:', stems)
|
| 137 |
+
const start = performance.now()
|
| 138 |
+
try {
|
| 139 |
+
await loadStems(sessionId, stems)
|
| 140 |
+
const end = performance.now()
|
| 141 |
+
console.log(`=== handleStemsReady completed in ${(end - start).toFixed(0)}ms ===`)
|
| 142 |
+
} catch (err) {
|
| 143 |
+
console.error('Failed to load stems:', err)
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
}, [sessionId, stems, loadStems])
|
| 147 |
+
|
| 148 |
+
// Track full song duration whenever we're in full mode and duration updates
|
| 149 |
+
useEffect(() => {
|
| 150 |
+
if (playbackMode === 'full' && duration > 0) {
|
| 151 |
+
setFullSongDuration(duration)
|
| 152 |
+
}
|
| 153 |
+
}, [playbackMode, duration])
|
| 154 |
+
|
| 155 |
+
// Show upload screen if no session
|
| 156 |
+
if (!sessionId) {
|
| 157 |
+
return (
|
| 158 |
+
<div className="min-h-screen flex items-center justify-center p-4">
|
| 159 |
+
<div className="w-full max-w-2xl">
|
| 160 |
+
<h1 className="text-4xl font-bold text-center mb-2 bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
| 161 |
+
Jam Track Studio
|
| 162 |
+
</h1>
|
| 163 |
+
<p className="text-gray-400 text-center mb-8">
|
| 164 |
+
Upload your stems, detect BPM & key, shift pitch and tempo, mix in real-time
|
| 165 |
+
</p>
|
| 166 |
+
|
| 167 |
+
<FileUpload
|
| 168 |
+
onUpload={handleUpload}
|
| 169 |
+
loading={loading}
|
| 170 |
+
error={error}
|
| 171 |
+
onClearError={clearError}
|
| 172 |
+
/>
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// Show analysis and controls
|
| 179 |
+
return (
|
| 180 |
+
<div className="min-h-screen p-4 md:p-8">
|
| 181 |
+
<div className="max-w-full mx-auto px-4">
|
| 182 |
+
{/* Header */}
|
| 183 |
+
<header className="mb-6 flex items-center justify-between">
|
| 184 |
+
<h1 className="text-2xl font-bold bg-gradient-to-r from-primary-400 to-accent-400 bg-clip-text text-transparent">
|
| 185 |
+
Jam Track Studio
|
| 186 |
+
</h1>
|
| 187 |
+
<button
|
| 188 |
+
onClick={() => window.location.reload()}
|
| 189 |
+
className="text-sm text-gray-400 hover:text-white transition-colors"
|
| 190 |
+
>
|
| 191 |
+
Upload New
|
| 192 |
+
</button>
|
| 193 |
+
</header>
|
| 194 |
+
|
| 195 |
+
{/* Main content grid */}
|
| 196 |
+
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
| 197 |
+
{/* Left column: Analysis + Controls */}
|
| 198 |
+
<div className="lg:col-span-3 space-y-6">
|
| 199 |
+
<AnalysisDisplay
|
| 200 |
+
detection={detection}
|
| 201 |
+
loading={loading}
|
| 202 |
+
onReady={handleStemsReady}
|
| 203 |
+
/>
|
| 204 |
+
|
| 205 |
+
<ControlPanel
|
| 206 |
+
detection={detection}
|
| 207 |
+
onProcess={handleProcess}
|
| 208 |
+
isProcessing={isProcessing}
|
| 209 |
+
hasRegion={hasRegion}
|
| 210 |
+
isGenerating={isGenerating}
|
| 211 |
+
onGenerate={handleGenerate}
|
| 212 |
+
sessionId={sessionId}
|
| 213 |
+
continuationReady={continuationReady}
|
| 214 |
+
/>
|
| 215 |
+
|
| 216 |
+
{/* Waveform visualization */}
|
| 217 |
+
<div className="glass rounded-xl p-4">
|
| 218 |
+
<Waveform analyserData={analyserData} isPlaying={isPlaying} />
|
| 219 |
+
</div>
|
| 220 |
+
|
| 221 |
+
{/* Transport controls */}
|
| 222 |
+
{isLoaded ? (
|
| 223 |
+
<TransportBar
|
| 224 |
+
isPlaying={isPlaying}
|
| 225 |
+
currentTime={currentTime}
|
| 226 |
+
duration={duration}
|
| 227 |
+
onPlay={play}
|
| 228 |
+
onPause={pause}
|
| 229 |
+
onStop={stop}
|
| 230 |
+
onSeek={seek}
|
| 231 |
+
regionStart={regionStart}
|
| 232 |
+
regionEnd={regionEnd}
|
| 233 |
+
onRegionChange={(start, end) => {
|
| 234 |
+
setRegionStart(start)
|
| 235 |
+
setRegionEnd(end)
|
| 236 |
+
}}
|
| 237 |
+
onClearRegion={() => {
|
| 238 |
+
setRegionStart(null)
|
| 239 |
+
setRegionEnd(null)
|
| 240 |
+
if (playbackMode === 'region') {
|
| 241 |
+
handlePlayFullSong()
|
| 242 |
+
}
|
| 243 |
+
}}
|
| 244 |
+
playbackMode={playbackMode}
|
| 245 |
+
onPlayFullSong={handlePlayFullSong}
|
| 246 |
+
fullSongDuration={fullSongDuration}
|
| 247 |
+
/>
|
| 248 |
+
) : (
|
| 249 |
+
<div className="glass rounded-xl p-4 text-center">
|
| 250 |
+
<div className="flex items-center justify-center gap-3 text-gray-400">
|
| 251 |
+
<div className="animate-spin text-2xl">⏳</div>
|
| 252 |
+
<span>Loading audio for playback...</span>
|
| 253 |
+
</div>
|
| 254 |
+
</div>
|
| 255 |
+
)}
|
| 256 |
+
</div>
|
| 257 |
+
|
| 258 |
+
{/* Right column: Stem Mixer */}
|
| 259 |
+
<div className="lg:col-span-2">
|
| 260 |
+
<StemMixer
|
| 261 |
+
stems={stems}
|
| 262 |
+
volumes={volumes}
|
| 263 |
+
solos={solos}
|
| 264 |
+
mutes={mutes}
|
| 265 |
+
reverbs={reverbs}
|
| 266 |
+
pans={pans}
|
| 267 |
+
isPlaying={isPlaying}
|
| 268 |
+
onVolumeChange={setVolume}
|
| 269 |
+
onSoloToggle={setSolo}
|
| 270 |
+
onMuteToggle={setMute}
|
| 271 |
+
onReverbChange={setReverb}
|
| 272 |
+
onPanChange={setPan}
|
| 273 |
+
onReset={resetVolumes}
|
| 274 |
+
/>
|
| 275 |
+
</div>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
{/* Error display */}
|
| 279 |
+
{error && (
|
| 280 |
+
<div className="fixed bottom-4 right-4 bg-red-500/90 text-white px-4 py-3 rounded-lg shadow-lg animate-fade-in">
|
| 281 |
+
<div className="flex items-center gap-3">
|
| 282 |
+
<span>{error}</span>
|
| 283 |
+
<button
|
| 284 |
+
onClick={clearError}
|
| 285 |
+
className="text-white/80 hover:text-white"
|
| 286 |
+
>
|
| 287 |
+
×
|
| 288 |
+
</button>
|
| 289 |
+
</div>
|
| 290 |
+
</div>
|
| 291 |
+
)}
|
| 292 |
+
</div>
|
| 293 |
+
|
| 294 |
+
{/* Processing overlay */}
|
| 295 |
+
{isProcessing && (
|
| 296 |
+
<ProcessingOverlay
|
| 297 |
+
stems={stems}
|
| 298 |
+
progress={processingProgress}
|
| 299 |
+
/>
|
| 300 |
+
)}
|
| 301 |
+
</div>
|
| 302 |
+
)
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
export default App
|
frontend/src/.backup/ControlPanel.jsx.bak
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
| 2 |
+
|
| 3 |
+
// All 24 key+mode combinations
|
| 4 |
+
const ALL_KEYS_WITH_MODES = [
|
| 5 |
+
'C major', 'C minor', 'C# major', 'C# minor',
|
| 6 |
+
'D major', 'D minor', 'D# major', 'D# minor',
|
| 7 |
+
'E major', 'E minor', 'F major', 'F minor',
|
| 8 |
+
'F# major', 'F# minor', 'G major', 'G minor',
|
| 9 |
+
'G# major', 'G# minor', 'A major', 'A minor',
|
| 10 |
+
'A# major', 'A# minor', 'B major', 'B minor'
|
| 11 |
+
]
|
| 12 |
+
|
| 13 |
+
const KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
| 14 |
+
|
| 15 |
+
function formatTime(seconds) {
|
| 16 |
+
if (!seconds || !isFinite(seconds)) return '0:00'
|
| 17 |
+
const mins = Math.floor(seconds / 60)
|
| 18 |
+
const secs = Math.floor(seconds % 60)
|
| 19 |
+
return `${mins}:${secs.toString().padStart(2, '0')}`
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function ContinuationPlayer({ sessionId }) {
|
| 23 |
+
const audioRef = useRef(null)
|
| 24 |
+
const [playing, setPlaying] = useState(false)
|
| 25 |
+
const [cTime, setCTime] = useState(0)
|
| 26 |
+
const [dur, setDur] = useState(0)
|
| 27 |
+
|
| 28 |
+
const src = `/api/stem/${sessionId}/_continuation?processed=false&t=${Date.now()}`
|
| 29 |
+
|
| 30 |
+
const toggle = () => {
|
| 31 |
+
if (!audioRef.current) return
|
| 32 |
+
if (playing) {
|
| 33 |
+
audioRef.current.pause()
|
| 34 |
+
} else {
|
| 35 |
+
audioRef.current.play()
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const handleSeek = (e) => {
|
| 40 |
+
if (!audioRef.current || !dur) return
|
| 41 |
+
const rect = e.currentTarget.getBoundingClientRect()
|
| 42 |
+
const pct = (e.clientX - rect.left) / rect.width
|
| 43 |
+
audioRef.current.currentTime = pct * dur
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
return (
|
| 47 |
+
<div className="mt-3 bg-green-500/10 border border-green-500/30 rounded-lg p-3">
|
| 48 |
+
<audio
|
| 49 |
+
ref={audioRef}
|
| 50 |
+
src={src}
|
| 51 |
+
preload="auto"
|
| 52 |
+
onTimeUpdate={() => setCTime(audioRef.current?.currentTime || 0)}
|
| 53 |
+
onLoadedMetadata={() => setDur(audioRef.current?.duration || 0)}
|
| 54 |
+
onPlay={() => setPlaying(true)}
|
| 55 |
+
onPause={() => setPlaying(false)}
|
| 56 |
+
onEnded={() => { setPlaying(false); setCTime(0) }}
|
| 57 |
+
/>
|
| 58 |
+
<div className="flex items-center gap-3">
|
| 59 |
+
<button
|
| 60 |
+
onClick={toggle}
|
| 61 |
+
className="w-9 h-9 rounded-full bg-green-500 hover:bg-green-400 flex items-center justify-center transition-colors flex-shrink-0"
|
| 62 |
+
>
|
| 63 |
+
{playing ? (
|
| 64 |
+
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
| 65 |
+
<rect x="6" y="4" width="4" height="16" />
|
| 66 |
+
<rect x="14" y="4" width="4" height="16" />
|
| 67 |
+
</svg>
|
| 68 |
+
) : (
|
| 69 |
+
<svg className="w-4 h-4 fill-white ml-0.5" viewBox="0 0 24 24">
|
| 70 |
+
<polygon points="5,3 19,12 5,21" />
|
| 71 |
+
</svg>
|
| 72 |
+
)}
|
| 73 |
+
</button>
|
| 74 |
+
|
| 75 |
+
<div className="flex-1 flex items-center gap-2">
|
| 76 |
+
<span className="text-xs text-gray-400 w-10 text-right font-mono">{formatTime(cTime)}</span>
|
| 77 |
+
<div
|
| 78 |
+
onClick={handleSeek}
|
| 79 |
+
className="flex-1 h-2 bg-gray-700/50 rounded-full cursor-pointer relative"
|
| 80 |
+
>
|
| 81 |
+
<div
|
| 82 |
+
className="h-full bg-green-500 rounded-full transition-all"
|
| 83 |
+
style={{ width: dur > 0 ? `${(cTime / dur) * 100}%` : '0%' }}
|
| 84 |
+
/>
|
| 85 |
+
</div>
|
| 86 |
+
<span className="text-xs text-gray-400 w-10 font-mono">{formatTime(dur)}</span>
|
| 87 |
+
</div>
|
| 88 |
+
</div>
|
| 89 |
+
<p className="text-xs text-green-400/70 mt-1.5">AI Continuation (seed + generated)</p>
|
| 90 |
+
</div>
|
| 91 |
+
)
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function ControlPanel({ detection, onProcess, isProcessing, hasRegion, isGenerating, onGenerate, sessionId, continuationReady }) {
|
| 95 |
+
const [targetKeyWithMode, setTargetKeyWithMode] = useState('C major')
|
| 96 |
+
const [targetBpm, setTargetBpm] = useState(120)
|
| 97 |
+
const [generationPrompt, setGenerationPrompt] = useState('')
|
| 98 |
+
|
| 99 |
+
// Initialize targets from detection
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
if (detection) {
|
| 102 |
+
setTargetKeyWithMode(`${detection.key} ${detection.mode}`)
|
| 103 |
+
setTargetBpm(detection.bpm)
|
| 104 |
+
}
|
| 105 |
+
}, [detection])
|
| 106 |
+
|
| 107 |
+
const targetKey = targetKeyWithMode.split(' ')[0]
|
| 108 |
+
|
| 109 |
+
const semitones = useMemo(() => {
|
| 110 |
+
if (!detection || !targetKey) return 0
|
| 111 |
+
const fromIdx = KEY_NAMES.indexOf(detection.key)
|
| 112 |
+
const toIdx = KEY_NAMES.indexOf(targetKey)
|
| 113 |
+
let diff = toIdx - fromIdx
|
| 114 |
+
if (diff > 6) diff -= 12
|
| 115 |
+
if (diff < -6) diff += 12
|
| 116 |
+
return diff
|
| 117 |
+
}, [detection, targetKey])
|
| 118 |
+
|
| 119 |
+
const bpmPercent = useMemo(() => {
|
| 120 |
+
if (!detection || !targetBpm) return 0
|
| 121 |
+
return Math.round(((targetBpm - detection.bpm) / detection.bpm) * 100)
|
| 122 |
+
}, [detection, targetBpm])
|
| 123 |
+
|
| 124 |
+
// Quality indicators per spec
|
| 125 |
+
const getKeyQualityBadge = (semitones) => {
|
| 126 |
+
const abs = Math.abs(semitones)
|
| 127 |
+
if (abs <= 4) return { color: 'bg-green-500', label: 'Recommended' }
|
| 128 |
+
if (abs <= 7) return { color: 'bg-yellow-500', label: 'Some quality loss' }
|
| 129 |
+
return { color: 'bg-red-500', label: 'Significant quality loss' }
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
const getBpmQualityBadge = (percent) => {
|
| 133 |
+
const abs = Math.abs(percent)
|
| 134 |
+
if (abs <= 20) return { color: 'bg-green-500', label: 'Recommended' }
|
| 135 |
+
if (abs <= 40) return { color: 'bg-yellow-500', label: 'Some quality loss' }
|
| 136 |
+
return { color: 'bg-red-500', label: 'Significant quality loss' }
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// Get slider background with colored zones
|
| 140 |
+
const getSliderBackground = () => {
|
| 141 |
+
// Green center (80-120%), yellow edges (60-80%, 120-140%), red extremes
|
| 142 |
+
return `linear-gradient(to right,
|
| 143 |
+
#ef4444 0%,
|
| 144 |
+
#eab308 25%,
|
| 145 |
+
#22c55e 40%,
|
| 146 |
+
#22c55e 60%,
|
| 147 |
+
#eab308 75%,
|
| 148 |
+
#ef4444 100%)`
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
const handleKeyShift = useCallback((shift) => {
|
| 152 |
+
const currentIdx = KEY_NAMES.indexOf(targetKey)
|
| 153 |
+
const newIdx = (currentIdx + shift + 12) % 12
|
| 154 |
+
const mode = targetKeyWithMode.split(' ')[1]
|
| 155 |
+
setTargetKeyWithMode(`${KEY_NAMES[newIdx]} ${mode}`)
|
| 156 |
+
}, [targetKey, targetKeyWithMode])
|
| 157 |
+
|
| 158 |
+
const handleApply = useCallback(() => {
|
| 159 |
+
if (!detection) return
|
| 160 |
+
const newBpm = Math.abs(targetBpm - detection.bpm) > 0.1 ? targetBpm : null
|
| 161 |
+
onProcess(semitones, newBpm)
|
| 162 |
+
}, [onProcess, semitones, targetBpm, detection])
|
| 163 |
+
|
| 164 |
+
const hasChanges = detection && (semitones !== 0 || Math.abs(targetBpm - detection.bpm) > 0.1)
|
| 165 |
+
|
| 166 |
+
if (!detection) {
|
| 167 |
+
return (
|
| 168 |
+
<div className="glass rounded-xl p-6">
|
| 169 |
+
<h2 className="text-lg font-semibold mb-4 text-white">Controls</h2>
|
| 170 |
+
<p className="text-gray-400">Waiting for analysis...</p>
|
| 171 |
+
</div>
|
| 172 |
+
)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const keyQuality = getKeyQualityBadge(semitones)
|
| 176 |
+
const bpmQuality = getBpmQualityBadge(bpmPercent)
|
| 177 |
+
|
| 178 |
+
return (
|
| 179 |
+
<div className="glass rounded-xl p-6 animate-fade-in">
|
| 180 |
+
<h2 className="text-lg font-semibold mb-6 text-white">Pitch & Tempo Controls</h2>
|
| 181 |
+
|
| 182 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
| 183 |
+
{/* Key Control */}
|
| 184 |
+
<div className="space-y-4">
|
| 185 |
+
<div className="flex items-center justify-between">
|
| 186 |
+
<label className="text-white font-medium">Key</label>
|
| 187 |
+
{semitones !== 0 && (
|
| 188 |
+
<div className="flex items-center gap-2">
|
| 189 |
+
<span className="text-gray-300 text-sm font-mono">
|
| 190 |
+
{semitones > 0 ? '+' : ''}{semitones} semitones
|
| 191 |
+
</span>
|
| 192 |
+
<span
|
| 193 |
+
className={`px-2 py-0.5 rounded text-xs font-medium text-white ${keyQuality.color} cursor-help`}
|
| 194 |
+
title="For best quality, stay within ±4 semitones of the original key"
|
| 195 |
+
>
|
| 196 |
+
{keyQuality.label}
|
| 197 |
+
</span>
|
| 198 |
+
</div>
|
| 199 |
+
)}
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
{/* Dropdown with all 24 keys */}
|
| 203 |
+
<select
|
| 204 |
+
value={targetKeyWithMode}
|
| 205 |
+
onChange={(e) => setTargetKeyWithMode(e.target.value)}
|
| 206 |
+
className="w-full bg-gray-800/80 border border-gray-600 rounded-lg px-4 py-3 text-white text-lg focus:outline-none focus:border-purple-500 backdrop-blur"
|
| 207 |
+
>
|
| 208 |
+
{ALL_KEYS_WITH_MODES.map(key => (
|
| 209 |
+
<option key={key} value={key}>{key}</option>
|
| 210 |
+
))}
|
| 211 |
+
</select>
|
| 212 |
+
|
| 213 |
+
{/* Quick shift buttons: -2, -1, +1, +2 */}
|
| 214 |
+
<div className="flex gap-2">
|
| 215 |
+
{[-2, -1, 1, 2].map(shift => (
|
| 216 |
+
<button
|
| 217 |
+
key={shift}
|
| 218 |
+
onClick={() => handleKeyShift(shift)}
|
| 219 |
+
className="flex-1 py-2 text-sm bg-gray-700/50 hover:bg-gray-600/50 rounded-lg transition-colors text-white border border-gray-600"
|
| 220 |
+
>
|
| 221 |
+
{shift > 0 ? '+' : ''}{shift}
|
| 222 |
+
</button>
|
| 223 |
+
))}
|
| 224 |
+
</div>
|
| 225 |
+
|
| 226 |
+
<p className="text-xs text-gray-500">
|
| 227 |
+
Original: {detection.key} {detection.mode}
|
| 228 |
+
</p>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
{/* BPM Control */}
|
| 232 |
+
<div className="space-y-4">
|
| 233 |
+
<div className="flex items-center justify-between">
|
| 234 |
+
<label className="text-white font-medium">BPM</label>
|
| 235 |
+
{bpmPercent !== 0 && (
|
| 236 |
+
<div className="flex items-center gap-2">
|
| 237 |
+
<span className="text-gray-300 text-sm font-mono">
|
| 238 |
+
{bpmPercent > 0 ? '+' : ''}{bpmPercent}%
|
| 239 |
+
</span>
|
| 240 |
+
<span
|
| 241 |
+
className={`px-2 py-0.5 rounded text-xs font-medium text-white ${bpmQuality.color} cursor-help`}
|
| 242 |
+
title="For best quality, stay within ±20% of the original BPM"
|
| 243 |
+
>
|
| 244 |
+
{bpmQuality.label}
|
| 245 |
+
</span>
|
| 246 |
+
</div>
|
| 247 |
+
)}
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
{/* Number input */}
|
| 251 |
+
<input
|
| 252 |
+
type="number"
|
| 253 |
+
value={Math.round(targetBpm)}
|
| 254 |
+
onChange={(e) => setTargetBpm(parseFloat(e.target.value) || detection.bpm)}
|
| 255 |
+
min={Math.round(detection.bpm * 0.5)}
|
| 256 |
+
max={Math.round(detection.bpm * 2)}
|
| 257 |
+
className="w-full bg-gray-800/80 border border-gray-600 rounded-lg px-4 py-3 text-white text-lg focus:outline-none focus:border-purple-500 backdrop-blur"
|
| 258 |
+
/>
|
| 259 |
+
|
| 260 |
+
{/* Slider with colored zones (50% to 200%) */}
|
| 261 |
+
<div className="relative">
|
| 262 |
+
<div
|
| 263 |
+
className="absolute inset-0 h-2 rounded-full top-1/2 -translate-y-1/2 pointer-events-none"
|
| 264 |
+
style={{ background: getSliderBackground() }}
|
| 265 |
+
/>
|
| 266 |
+
<input
|
| 267 |
+
type="range"
|
| 268 |
+
value={targetBpm}
|
| 269 |
+
onChange={(e) => setTargetBpm(parseFloat(e.target.value))}
|
| 270 |
+
min={detection.bpm * 0.5}
|
| 271 |
+
max={detection.bpm * 2}
|
| 272 |
+
step={1}
|
| 273 |
+
className="relative w-full h-2 bg-transparent rounded-lg appearance-none cursor-pointer z-10"
|
| 274 |
+
style={{ WebkitAppearance: 'none' }}
|
| 275 |
+
/>
|
| 276 |
+
</div>
|
| 277 |
+
<div className="flex justify-between text-xs text-gray-500">
|
| 278 |
+
<span>50%</span>
|
| 279 |
+
<span>100%</span>
|
| 280 |
+
<span>200%</span>
|
| 281 |
+
</div>
|
| 282 |
+
|
| 283 |
+
{/* Quick BPM buttons: -10, -5, +5, +10 */}
|
| 284 |
+
<div className="flex gap-2">
|
| 285 |
+
{[-10, -5, 5, 10].map(shift => (
|
| 286 |
+
<button
|
| 287 |
+
key={shift}
|
| 288 |
+
onClick={() => setTargetBpm(Math.max(20, targetBpm + shift))}
|
| 289 |
+
className="flex-1 py-2 text-sm bg-gray-700/50 hover:bg-gray-600/50 rounded-lg transition-colors text-white border border-gray-600"
|
| 290 |
+
>
|
| 291 |
+
{shift > 0 ? '+' : ''}{shift}
|
| 292 |
+
</button>
|
| 293 |
+
))}
|
| 294 |
+
</div>
|
| 295 |
+
|
| 296 |
+
<p className="text-xs text-gray-500">
|
| 297 |
+
Original: {detection.bpm.toFixed(1)} BPM
|
| 298 |
+
</p>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
|
| 302 |
+
{/* Apply Changes button */}
|
| 303 |
+
<button
|
| 304 |
+
onClick={handleApply}
|
| 305 |
+
disabled={!hasChanges || isProcessing}
|
| 306 |
+
className={`w-full mt-8 py-4 rounded-xl font-semibold text-lg transition-all ${
|
| 307 |
+
!hasChanges || isProcessing
|
| 308 |
+
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
| 309 |
+
: 'bg-gradient-to-r from-blue-500 to-purple-500 hover:from-blue-400 hover:to-purple-400 text-white shadow-lg hover:shadow-purple-500/25'
|
| 310 |
+
}`}
|
| 311 |
+
>
|
| 312 |
+
{isProcessing ? 'Processing...' : hasChanges ? (hasRegion ? 'Apply to Selection' : 'Apply Changes') : 'No Changes'}
|
| 313 |
+
</button>
|
| 314 |
+
|
| 315 |
+
{/* AI Continuation — visible when region is selected */}
|
| 316 |
+
{hasRegion && (
|
| 317 |
+
<div className="mt-6 pt-6 border-t border-gray-700/50">
|
| 318 |
+
<h3 className="text-sm font-semibold text-white mb-3">AI Continuation</h3>
|
| 319 |
+
<input
|
| 320 |
+
type="text"
|
| 321 |
+
value={generationPrompt}
|
| 322 |
+
onChange={(e) => setGenerationPrompt(e.target.value)}
|
| 323 |
+
placeholder="Describe the continuation style (optional)..."
|
| 324 |
+
disabled={isGenerating || isProcessing}
|
| 325 |
+
className="w-full bg-gray-800/80 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm focus:outline-none focus:border-green-500 backdrop-blur placeholder-gray-500 mb-3"
|
| 326 |
+
/>
|
| 327 |
+
<button
|
| 328 |
+
onClick={() => onGenerate(generationPrompt)}
|
| 329 |
+
disabled={isGenerating || isProcessing}
|
| 330 |
+
className={`w-full py-3 rounded-xl font-semibold text-sm transition-all ${
|
| 331 |
+
isGenerating || isProcessing
|
| 332 |
+
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
| 333 |
+
: 'bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-400 hover:to-emerald-400 text-white shadow-lg hover:shadow-green-500/25'
|
| 334 |
+
}`}
|
| 335 |
+
>
|
| 336 |
+
{isGenerating ? 'Generating...' : 'Generate AI Continuation (15s)'}
|
| 337 |
+
</button>
|
| 338 |
+
|
| 339 |
+
{/* Dedicated continuation player */}
|
| 340 |
+
{continuationReady && sessionId && (
|
| 341 |
+
<ContinuationPlayer sessionId={sessionId} />
|
| 342 |
+
)}
|
| 343 |
+
</div>
|
| 344 |
+
)}
|
| 345 |
+
</div>
|
| 346 |
+
)
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
export default ControlPanel
|
frontend/src/.backup/useSession.js.bak
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from 'react'
|
| 2 |
+
|
| 3 |
+
export function useSession() {
|
| 4 |
+
const [sessionId, setSessionId] = useState(null)
|
| 5 |
+
const [stems, setStems] = useState([])
|
| 6 |
+
const [detection, setDetection] = useState(null)
|
| 7 |
+
const [loading, setLoading] = useState(false)
|
| 8 |
+
const [error, setError] = useState(null)
|
| 9 |
+
|
| 10 |
+
const clearError = useCallback(() => {
|
| 11 |
+
setError(null)
|
| 12 |
+
}, [])
|
| 13 |
+
|
| 14 |
+
// Upload files and automatically run detection
|
| 15 |
+
const upload = useCallback(async (formData) => {
|
| 16 |
+
setLoading(true)
|
| 17 |
+
setError(null)
|
| 18 |
+
|
| 19 |
+
try {
|
| 20 |
+
// Step 1: Upload files (formData already prepared by FileUpload component)
|
| 21 |
+
const uploadResponse = await fetch('/api/upload', {
|
| 22 |
+
method: 'POST',
|
| 23 |
+
body: formData
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
if (!uploadResponse.ok) {
|
| 27 |
+
const data = await uploadResponse.json().catch(() => ({}))
|
| 28 |
+
throw new Error(data.detail || `Upload failed: ${uploadResponse.status}`)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const uploadData = await uploadResponse.json()
|
| 32 |
+
console.log('Upload response:', uploadData)
|
| 33 |
+
|
| 34 |
+
setSessionId(uploadData.session_id)
|
| 35 |
+
setStems(uploadData.stems)
|
| 36 |
+
|
| 37 |
+
// Step 2: Automatically run detection with the session_id we just got
|
| 38 |
+
const detectResponse = await fetch(`/api/detect/${uploadData.session_id}`, {
|
| 39 |
+
method: 'POST'
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
if (!detectResponse.ok) {
|
| 43 |
+
const data = await detectResponse.json().catch(() => ({}))
|
| 44 |
+
throw new Error(data.detail || `Detection failed: ${detectResponse.status}`)
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
const detectData = await detectResponse.json()
|
| 48 |
+
console.log('Detection response:', detectData)
|
| 49 |
+
setDetection(detectData)
|
| 50 |
+
|
| 51 |
+
return { upload: uploadData, detection: detectData }
|
| 52 |
+
} catch (err) {
|
| 53 |
+
console.error('Error:', err)
|
| 54 |
+
setError(err.message)
|
| 55 |
+
return null
|
| 56 |
+
} finally {
|
| 57 |
+
setLoading(false)
|
| 58 |
+
}
|
| 59 |
+
}, [])
|
| 60 |
+
|
| 61 |
+
// Manual detect (if needed separately)
|
| 62 |
+
const detect = useCallback(async (sid = null) => {
|
| 63 |
+
const id = sid || sessionId
|
| 64 |
+
if (!id) {
|
| 65 |
+
setError('No session to detect')
|
| 66 |
+
return null
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
setLoading(true)
|
| 70 |
+
setError(null)
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
const response = await fetch(`/api/detect/${id}`, {
|
| 74 |
+
method: 'POST'
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
if (!response.ok) {
|
| 78 |
+
const data = await response.json().catch(() => ({}))
|
| 79 |
+
throw new Error(data.detail || `Detection failed: ${response.status}`)
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
const data = await response.json()
|
| 83 |
+
setDetection(data)
|
| 84 |
+
return data
|
| 85 |
+
} catch (err) {
|
| 86 |
+
setError(err.message)
|
| 87 |
+
return null
|
| 88 |
+
} finally {
|
| 89 |
+
setLoading(false)
|
| 90 |
+
}
|
| 91 |
+
}, [sessionId])
|
| 92 |
+
|
| 93 |
+
const process = useCallback(async (semitones, targetBpm = null, regionStart = null, regionEnd = null) => {
|
| 94 |
+
if (!sessionId) {
|
| 95 |
+
setError('No session to process')
|
| 96 |
+
return null
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
setLoading(true)
|
| 100 |
+
setError(null)
|
| 101 |
+
|
| 102 |
+
try {
|
| 103 |
+
const body = { semitones, target_bpm: targetBpm }
|
| 104 |
+
if (regionStart !== null && regionEnd !== null) {
|
| 105 |
+
body.region_start = regionStart
|
| 106 |
+
body.region_end = regionEnd
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const response = await fetch(`/api/process/${sessionId}`, {
|
| 110 |
+
method: 'POST',
|
| 111 |
+
headers: {
|
| 112 |
+
'Content-Type': 'application/json'
|
| 113 |
+
},
|
| 114 |
+
body: JSON.stringify(body)
|
| 115 |
+
})
|
| 116 |
+
|
| 117 |
+
if (!response.ok) {
|
| 118 |
+
const data = await response.json().catch(() => ({}))
|
| 119 |
+
throw new Error(data.detail || `Processing failed: ${response.status}`)
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const data = await response.json()
|
| 123 |
+
return data
|
| 124 |
+
} catch (err) {
|
| 125 |
+
setError(err.message)
|
| 126 |
+
return null
|
| 127 |
+
} finally {
|
| 128 |
+
setLoading(false)
|
| 129 |
+
}
|
| 130 |
+
}, [sessionId])
|
| 131 |
+
|
| 132 |
+
const getStems = useCallback(async () => {
|
| 133 |
+
if (!sessionId) return null
|
| 134 |
+
|
| 135 |
+
try {
|
| 136 |
+
const response = await fetch(`/api/stems/${sessionId}`)
|
| 137 |
+
|
| 138 |
+
if (!response.ok) {
|
| 139 |
+
throw new Error('Failed to get stems')
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const data = await response.json()
|
| 143 |
+
return data
|
| 144 |
+
} catch (err) {
|
| 145 |
+
setError(err.message)
|
| 146 |
+
return null
|
| 147 |
+
}
|
| 148 |
+
}, [sessionId])
|
| 149 |
+
|
| 150 |
+
const generate = useCallback(async (regionStart, regionEnd, duration = 15.0, prompt = null) => {
|
| 151 |
+
if (!sessionId) {
|
| 152 |
+
setError('No session to generate')
|
| 153 |
+
return null
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
setLoading(true)
|
| 157 |
+
setError(null)
|
| 158 |
+
|
| 159 |
+
try {
|
| 160 |
+
const body = { region_start: regionStart, region_end: regionEnd, duration }
|
| 161 |
+
if (prompt) {
|
| 162 |
+
body.prompt = prompt
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const response = await fetch(`/api/generate/${sessionId}`, {
|
| 166 |
+
method: 'POST',
|
| 167 |
+
headers: {
|
| 168 |
+
'Content-Type': 'application/json'
|
| 169 |
+
},
|
| 170 |
+
body: JSON.stringify(body)
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
if (!response.ok) {
|
| 174 |
+
const data = await response.json().catch(() => ({}))
|
| 175 |
+
throw new Error(data.detail || `Generation failed: ${response.status}`)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const data = await response.json()
|
| 179 |
+
return data
|
| 180 |
+
} catch (err) {
|
| 181 |
+
setError(err.message)
|
| 182 |
+
return null
|
| 183 |
+
} finally {
|
| 184 |
+
setLoading(false)
|
| 185 |
+
}
|
| 186 |
+
}, [sessionId])
|
| 187 |
+
|
| 188 |
+
return {
|
| 189 |
+
sessionId,
|
| 190 |
+
stems,
|
| 191 |
+
detection,
|
| 192 |
+
loading,
|
| 193 |
+
error,
|
| 194 |
+
upload,
|
| 195 |
+
detect,
|
| 196 |
+
process,
|
| 197 |
+
generate,
|
| 198 |
+
getStems,
|
| 199 |
+
clearError
|
| 200 |
+
}
|
| 201 |
+
}
|
frontend/src/App.jsx
CHANGED
|
@@ -20,6 +20,7 @@ function App() {
|
|
| 20 |
upload,
|
| 21 |
detect,
|
| 22 |
process,
|
|
|
|
| 23 |
clearError
|
| 24 |
} = useSession()
|
| 25 |
|
|
@@ -50,6 +51,8 @@ function App() {
|
|
| 50 |
} = useAudioEngine()
|
| 51 |
|
| 52 |
const [isProcessing, setIsProcessing] = useState(false)
|
|
|
|
|
|
|
| 53 |
const { progress: processingProgress, resetProgress } = useProcessingProgress(sessionId)
|
| 54 |
|
| 55 |
// Region selection state
|
|
@@ -97,6 +100,22 @@ function App() {
|
|
| 97 |
}
|
| 98 |
}, [process, sessionId, stems, loadStems, resetProgress, hasRegion, regionStart, regionEnd, setLoop, clearBufferCache])
|
| 99 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
const handlePlayFullSong = useCallback(async () => {
|
| 101 |
// Stop current playback
|
| 102 |
stop()
|
|
@@ -188,6 +207,10 @@ function App() {
|
|
| 188 |
onProcess={handleProcess}
|
| 189 |
isProcessing={isProcessing}
|
| 190 |
hasRegion={hasRegion}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
/>
|
| 192 |
|
| 193 |
{/* Waveform visualization */}
|
|
|
|
| 20 |
upload,
|
| 21 |
detect,
|
| 22 |
process,
|
| 23 |
+
generate,
|
| 24 |
clearError
|
| 25 |
} = useSession()
|
| 26 |
|
|
|
|
| 51 |
} = useAudioEngine()
|
| 52 |
|
| 53 |
const [isProcessing, setIsProcessing] = useState(false)
|
| 54 |
+
const [isGenerating, setIsGenerating] = useState(false)
|
| 55 |
+
const [continuationReady, setContinuationReady] = useState(false)
|
| 56 |
const { progress: processingProgress, resetProgress } = useProcessingProgress(sessionId)
|
| 57 |
|
| 58 |
// Region selection state
|
|
|
|
| 100 |
}
|
| 101 |
}, [process, sessionId, stems, loadStems, resetProgress, hasRegion, regionStart, regionEnd, setLoop, clearBufferCache])
|
| 102 |
|
| 103 |
+
const handleGenerate = useCallback(async (prompt) => {
|
| 104 |
+
if (!hasRegion) return
|
| 105 |
+
setIsGenerating(true)
|
| 106 |
+
setContinuationReady(false)
|
| 107 |
+
resetProgress()
|
| 108 |
+
|
| 109 |
+
try {
|
| 110 |
+
const result = await generate(regionStart, regionEnd, 15.0, prompt || null)
|
| 111 |
+
if (result?.success) {
|
| 112 |
+
setContinuationReady(true)
|
| 113 |
+
}
|
| 114 |
+
} finally {
|
| 115 |
+
setIsGenerating(false)
|
| 116 |
+
}
|
| 117 |
+
}, [generate, hasRegion, regionStart, regionEnd, resetProgress])
|
| 118 |
+
|
| 119 |
const handlePlayFullSong = useCallback(async () => {
|
| 120 |
// Stop current playback
|
| 121 |
stop()
|
|
|
|
| 207 |
onProcess={handleProcess}
|
| 208 |
isProcessing={isProcessing}
|
| 209 |
hasRegion={hasRegion}
|
| 210 |
+
isGenerating={isGenerating}
|
| 211 |
+
onGenerate={handleGenerate}
|
| 212 |
+
sessionId={sessionId}
|
| 213 |
+
continuationReady={continuationReady}
|
| 214 |
/>
|
| 215 |
|
| 216 |
{/* Waveform visualization */}
|
frontend/src/components/ControlPanel.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import React, { useState, useMemo, useCallback, useEffect } from 'react'
|
| 2 |
|
| 3 |
// All 24 key+mode combinations
|
| 4 |
const ALL_KEYS_WITH_MODES = [
|
|
@@ -12,9 +12,128 @@ const ALL_KEYS_WITH_MODES = [
|
|
| 12 |
|
| 13 |
const KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
| 14 |
|
| 15 |
-
function
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
const [targetKeyWithMode, setTargetKeyWithMode] = useState('C major')
|
| 17 |
const [targetBpm, setTargetBpm] = useState(120)
|
|
|
|
| 18 |
|
| 19 |
// Initialize targets from detection
|
| 20 |
useEffect(() => {
|
|
@@ -231,6 +350,37 @@ function ControlPanel({ detection, onProcess, isProcessing, hasRegion }) {
|
|
| 231 |
>
|
| 232 |
{isProcessing ? 'Processing...' : hasChanges ? (hasRegion ? 'Apply to Selection' : 'Apply Changes') : 'No Changes'}
|
| 233 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
</div>
|
| 235 |
)
|
| 236 |
}
|
|
|
|
| 1 |
+
import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react'
|
| 2 |
|
| 3 |
// All 24 key+mode combinations
|
| 4 |
const ALL_KEYS_WITH_MODES = [
|
|
|
|
| 12 |
|
| 13 |
const KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
|
| 14 |
|
| 15 |
+
function formatTime(seconds) {
|
| 16 |
+
if (!seconds || !isFinite(seconds)) return '0:00'
|
| 17 |
+
const mins = Math.floor(seconds / 60)
|
| 18 |
+
const secs = Math.floor(seconds % 60)
|
| 19 |
+
return `${mins}:${secs.toString().padStart(2, '0')}`
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function ContinuationPlayer({ sessionId }) {
|
| 23 |
+
const audioRef = useRef(null)
|
| 24 |
+
const rafRef = useRef(null)
|
| 25 |
+
const [playing, setPlaying] = useState(false)
|
| 26 |
+
const [cTime, setCTime] = useState(0)
|
| 27 |
+
const [dur, setDur] = useState(0)
|
| 28 |
+
|
| 29 |
+
// Stable URL — only changes when sessionId changes, not on every render
|
| 30 |
+
const src = useMemo(
|
| 31 |
+
() => `/api/stem/${sessionId}/_continuation?processed=false&t=${Date.now()}`,
|
| 32 |
+
[sessionId]
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
// Try to read duration from the audio element (WAV streams may delay reporting it)
|
| 36 |
+
const tryReadDuration = useCallback(() => {
|
| 37 |
+
const audio = audioRef.current
|
| 38 |
+
if (!audio) return
|
| 39 |
+
const d = audio.duration
|
| 40 |
+
if (d && isFinite(d) && d > 0) {
|
| 41 |
+
setDur(d)
|
| 42 |
+
}
|
| 43 |
+
}, [])
|
| 44 |
+
|
| 45 |
+
// Smooth animation loop — reads currentTime every frame while playing
|
| 46 |
+
useEffect(() => {
|
| 47 |
+
if (!playing) {
|
| 48 |
+
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
| 49 |
+
return
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const tick = () => {
|
| 53 |
+
if (audioRef.current) {
|
| 54 |
+
setCTime(audioRef.current.currentTime)
|
| 55 |
+
tryReadDuration()
|
| 56 |
+
}
|
| 57 |
+
rafRef.current = requestAnimationFrame(tick)
|
| 58 |
+
}
|
| 59 |
+
rafRef.current = requestAnimationFrame(tick)
|
| 60 |
+
|
| 61 |
+
return () => {
|
| 62 |
+
if (rafRef.current) cancelAnimationFrame(rafRef.current)
|
| 63 |
+
}
|
| 64 |
+
}, [playing, tryReadDuration])
|
| 65 |
+
|
| 66 |
+
const toggle = () => {
|
| 67 |
+
if (!audioRef.current) return
|
| 68 |
+
if (playing) {
|
| 69 |
+
audioRef.current.pause()
|
| 70 |
+
} else {
|
| 71 |
+
audioRef.current.play()
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
const handleSeek = (e) => {
|
| 76 |
+
const d = dur || audioRef.current?.duration
|
| 77 |
+
if (!audioRef.current || !d || !isFinite(d)) return
|
| 78 |
+
const rect = e.currentTarget.getBoundingClientRect()
|
| 79 |
+
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
| 80 |
+
audioRef.current.currentTime = pct * d
|
| 81 |
+
setCTime(audioRef.current.currentTime)
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
return (
|
| 85 |
+
<div className="mt-3 bg-green-500/10 border border-green-500/30 rounded-lg p-3">
|
| 86 |
+
<audio
|
| 87 |
+
ref={audioRef}
|
| 88 |
+
src={src}
|
| 89 |
+
preload="auto"
|
| 90 |
+
onLoadedMetadata={tryReadDuration}
|
| 91 |
+
onDurationChange={tryReadDuration}
|
| 92 |
+
onCanPlayThrough={tryReadDuration}
|
| 93 |
+
onPlay={() => setPlaying(true)}
|
| 94 |
+
onPause={() => setPlaying(false)}
|
| 95 |
+
onEnded={() => { setPlaying(false); setCTime(0) }}
|
| 96 |
+
/>
|
| 97 |
+
<div className="flex items-center gap-3">
|
| 98 |
+
<button
|
| 99 |
+
onClick={toggle}
|
| 100 |
+
className="w-9 h-9 rounded-full bg-green-500 hover:bg-green-400 flex items-center justify-center transition-colors flex-shrink-0"
|
| 101 |
+
>
|
| 102 |
+
{playing ? (
|
| 103 |
+
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
|
| 104 |
+
<rect x="6" y="4" width="4" height="16" />
|
| 105 |
+
<rect x="14" y="4" width="4" height="16" />
|
| 106 |
+
</svg>
|
| 107 |
+
) : (
|
| 108 |
+
<svg className="w-4 h-4 fill-white ml-0.5" viewBox="0 0 24 24">
|
| 109 |
+
<polygon points="5,3 19,12 5,21" />
|
| 110 |
+
</svg>
|
| 111 |
+
)}
|
| 112 |
+
</button>
|
| 113 |
+
|
| 114 |
+
<div className="flex-1 flex items-center gap-2">
|
| 115 |
+
<span className="text-xs text-gray-400 w-10 text-right font-mono">{formatTime(cTime)}</span>
|
| 116 |
+
<div
|
| 117 |
+
onClick={handleSeek}
|
| 118 |
+
className="flex-1 h-2 bg-gray-700/50 rounded-full cursor-pointer relative"
|
| 119 |
+
>
|
| 120 |
+
<div
|
| 121 |
+
className="h-full bg-green-500 rounded-full"
|
| 122 |
+
style={{ width: dur > 0 ? `${(cTime / dur) * 100}%` : '0%' }}
|
| 123 |
+
/>
|
| 124 |
+
</div>
|
| 125 |
+
<span className="text-xs text-gray-400 w-10 font-mono">{formatTime(dur)}</span>
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
<p className="text-xs text-green-400/70 mt-1.5">AI Continuation (seed + generated)</p>
|
| 129 |
+
</div>
|
| 130 |
+
)
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function ControlPanel({ detection, onProcess, isProcessing, hasRegion, isGenerating, onGenerate, sessionId, continuationReady }) {
|
| 134 |
const [targetKeyWithMode, setTargetKeyWithMode] = useState('C major')
|
| 135 |
const [targetBpm, setTargetBpm] = useState(120)
|
| 136 |
+
const [generationPrompt, setGenerationPrompt] = useState('')
|
| 137 |
|
| 138 |
// Initialize targets from detection
|
| 139 |
useEffect(() => {
|
|
|
|
| 350 |
>
|
| 351 |
{isProcessing ? 'Processing...' : hasChanges ? (hasRegion ? 'Apply to Selection' : 'Apply Changes') : 'No Changes'}
|
| 352 |
</button>
|
| 353 |
+
|
| 354 |
+
{/* AI Continuation — visible when region is selected */}
|
| 355 |
+
{hasRegion && (
|
| 356 |
+
<div className="mt-6 pt-6 border-t border-gray-700/50">
|
| 357 |
+
<h3 className="text-sm font-semibold text-white mb-3">AI Continuation</h3>
|
| 358 |
+
<input
|
| 359 |
+
type="text"
|
| 360 |
+
value={generationPrompt}
|
| 361 |
+
onChange={(e) => setGenerationPrompt(e.target.value)}
|
| 362 |
+
placeholder="Describe the continuation style (optional)..."
|
| 363 |
+
disabled={isGenerating || isProcessing}
|
| 364 |
+
className="w-full bg-gray-800/80 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm focus:outline-none focus:border-green-500 backdrop-blur placeholder-gray-500 mb-3"
|
| 365 |
+
/>
|
| 366 |
+
<button
|
| 367 |
+
onClick={() => onGenerate(generationPrompt)}
|
| 368 |
+
disabled={isGenerating || isProcessing}
|
| 369 |
+
className={`w-full py-3 rounded-xl font-semibold text-sm transition-all ${
|
| 370 |
+
isGenerating || isProcessing
|
| 371 |
+
? 'bg-gray-700 text-gray-500 cursor-not-allowed'
|
| 372 |
+
: 'bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-400 hover:to-emerald-400 text-white shadow-lg hover:shadow-green-500/25'
|
| 373 |
+
}`}
|
| 374 |
+
>
|
| 375 |
+
{isGenerating ? 'Generating...' : 'Generate AI Continuation (15s)'}
|
| 376 |
+
</button>
|
| 377 |
+
|
| 378 |
+
{/* Dedicated continuation player */}
|
| 379 |
+
{continuationReady && sessionId && (
|
| 380 |
+
<ContinuationPlayer sessionId={sessionId} />
|
| 381 |
+
)}
|
| 382 |
+
</div>
|
| 383 |
+
)}
|
| 384 |
</div>
|
| 385 |
)
|
| 386 |
}
|
frontend/src/hooks/useAudioEngine.js
CHANGED
|
@@ -123,30 +123,36 @@ export function useAudioEngine() {
|
|
| 123 |
return { stem, audioBuffer: bufferCacheRef.current[cacheKey] }
|
| 124 |
}
|
| 125 |
|
| 126 |
-
// Cache miss — fetch and
|
| 127 |
const stemStart = performance.now()
|
| 128 |
try {
|
| 129 |
-
console.log(`[${stem}] CACHE MISS — fetching...`)
|
| 130 |
const fetchStart = performance.now()
|
| 131 |
const regionParam = region ? '®ion=true' : ''
|
| 132 |
-
let response = await fetch(`/api/stem/${sessionId}/${stem}?processed=true${regionParam}`)
|
| 133 |
if (!response.ok) {
|
| 134 |
-
response = await fetch(`/api/stem/${sessionId}/${stem}?processed=false`)
|
| 135 |
}
|
| 136 |
const fetchEnd = performance.now()
|
| 137 |
console.log(`[${stem}] Fetch completed in ${(fetchEnd - fetchStart).toFixed(0)}ms`)
|
| 138 |
|
| 139 |
if (response.ok) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
const bufferStart = performance.now()
|
| 141 |
const arrayBuffer = await response.arrayBuffer()
|
| 142 |
const bufferEnd = performance.now()
|
| 143 |
const sizeMB = (arrayBuffer.byteLength / 1024 / 1024).toFixed(2)
|
| 144 |
console.log(`[${stem}] ArrayBuffer: ${sizeMB}MB in ${(bufferEnd - bufferStart).toFixed(0)}ms`)
|
| 145 |
|
| 146 |
-
const
|
| 147 |
-
const
|
| 148 |
-
const
|
| 149 |
-
|
|
|
|
|
|
|
| 150 |
|
| 151 |
// Store in persistent cache
|
| 152 |
bufferCacheRef.current[cacheKey] = audioBuffer
|
|
|
|
| 123 |
return { stem, audioBuffer: bufferCacheRef.current[cacheKey] }
|
| 124 |
}
|
| 125 |
|
| 126 |
+
// Cache miss — fetch raw PCM and construct AudioBuffer directly (no decodeAudioData)
|
| 127 |
const stemStart = performance.now()
|
| 128 |
try {
|
| 129 |
+
console.log(`[${stem}] CACHE MISS — fetching PCM...`)
|
| 130 |
const fetchStart = performance.now()
|
| 131 |
const regionParam = region ? '®ion=true' : ''
|
| 132 |
+
let response = await fetch(`/api/stem/${sessionId}/${stem}?processed=true${regionParam}&format=pcm`)
|
| 133 |
if (!response.ok) {
|
| 134 |
+
response = await fetch(`/api/stem/${sessionId}/${stem}?processed=false&format=pcm`)
|
| 135 |
}
|
| 136 |
const fetchEnd = performance.now()
|
| 137 |
console.log(`[${stem}] Fetch completed in ${(fetchEnd - fetchStart).toFixed(0)}ms`)
|
| 138 |
|
| 139 |
if (response.ok) {
|
| 140 |
+
const sampleRate = parseInt(response.headers.get('X-Sample-Rate'))
|
| 141 |
+
const numChannels = parseInt(response.headers.get('X-Channels'))
|
| 142 |
+
const numFrames = parseInt(response.headers.get('X-Frames'))
|
| 143 |
+
|
| 144 |
const bufferStart = performance.now()
|
| 145 |
const arrayBuffer = await response.arrayBuffer()
|
| 146 |
const bufferEnd = performance.now()
|
| 147 |
const sizeMB = (arrayBuffer.byteLength / 1024 / 1024).toFixed(2)
|
| 148 |
console.log(`[${stem}] ArrayBuffer: ${sizeMB}MB in ${(bufferEnd - bufferStart).toFixed(0)}ms`)
|
| 149 |
|
| 150 |
+
const constructStart = performance.now()
|
| 151 |
+
const float32 = new Float32Array(arrayBuffer)
|
| 152 |
+
const audioBuffer = ctx.createBuffer(numChannels, numFrames, sampleRate)
|
| 153 |
+
audioBuffer.copyToChannel(float32, 0)
|
| 154 |
+
const constructEnd = performance.now()
|
| 155 |
+
console.log(`[${stem}] Constructed ${audioBuffer.duration.toFixed(1)}s AudioBuffer in ${(constructEnd - constructStart).toFixed(0)}ms`)
|
| 156 |
|
| 157 |
// Store in persistent cache
|
| 158 |
bufferCacheRef.current[cacheKey] = audioBuffer
|
frontend/src/hooks/useSession.js
CHANGED
|
@@ -147,6 +147,44 @@ export function useSession() {
|
|
| 147 |
}
|
| 148 |
}, [sessionId])
|
| 149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
return {
|
| 151 |
sessionId,
|
| 152 |
stems,
|
|
@@ -156,6 +194,7 @@ export function useSession() {
|
|
| 156 |
upload,
|
| 157 |
detect,
|
| 158 |
process,
|
|
|
|
| 159 |
getStems,
|
| 160 |
clearError
|
| 161 |
}
|
|
|
|
| 147 |
}
|
| 148 |
}, [sessionId])
|
| 149 |
|
| 150 |
+
const generate = useCallback(async (regionStart, regionEnd, duration = 15.0, prompt = null) => {
|
| 151 |
+
if (!sessionId) {
|
| 152 |
+
setError('No session to generate')
|
| 153 |
+
return null
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
setLoading(true)
|
| 157 |
+
setError(null)
|
| 158 |
+
|
| 159 |
+
try {
|
| 160 |
+
const body = { region_start: regionStart, region_end: regionEnd, duration }
|
| 161 |
+
if (prompt) {
|
| 162 |
+
body.prompt = prompt
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const response = await fetch(`/api/generate/${sessionId}`, {
|
| 166 |
+
method: 'POST',
|
| 167 |
+
headers: {
|
| 168 |
+
'Content-Type': 'application/json'
|
| 169 |
+
},
|
| 170 |
+
body: JSON.stringify(body)
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
if (!response.ok) {
|
| 174 |
+
const data = await response.json().catch(() => ({}))
|
| 175 |
+
throw new Error(data.detail || `Generation failed: ${response.status}`)
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const data = await response.json()
|
| 179 |
+
return data
|
| 180 |
+
} catch (err) {
|
| 181 |
+
setError(err.message)
|
| 182 |
+
return null
|
| 183 |
+
} finally {
|
| 184 |
+
setLoading(false)
|
| 185 |
+
}
|
| 186 |
+
}, [sessionId])
|
| 187 |
+
|
| 188 |
return {
|
| 189 |
sessionId,
|
| 190 |
stems,
|
|
|
|
| 194 |
upload,
|
| 195 |
detect,
|
| 196 |
process,
|
| 197 |
+
generate,
|
| 198 |
getStems,
|
| 199 |
clearError
|
| 200 |
}
|