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 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'attachment; filename="{stem_name}.wav"'
 
 
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
+ &times;
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 ControlPanel({ detection, onProcess, isProcessing, hasRegion }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 decode
127
  const stemStart = performance.now()
128
  try {
129
- console.log(`[${stem}] CACHE MISS — fetching...`)
130
  const fetchStart = performance.now()
131
  const regionParam = region ? '&region=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 decodeStart = performance.now()
147
- const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
148
- const decodeEnd = performance.now()
149
- console.log(`[${stem}] Decode: ${audioBuffer.duration.toFixed(1)}s audio in ${(decodeEnd - decodeStart).toFixed(0)}ms`)
 
 
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 ? '&region=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
  }