Mina Emadi
implemented some UI changes including adding a knob for pan and reverb, changing the UI of the key and bpm modification and changing the appearance of the detected key and bpm and removed some dev
e631a15 | import React, { useState, useRef, useMemo, useCallback, useEffect } from 'react' | |
| // All 24 key+mode combinations | |
| const ALL_KEYS_WITH_MODES = [ | |
| 'C major', 'C minor', 'C# major', 'C# minor', | |
| 'D major', 'D minor', 'D# major', 'D# minor', | |
| 'E major', 'E minor', 'F major', 'F minor', | |
| 'F# major', 'F# minor', 'G major', 'G minor', | |
| 'G# major', 'G# minor', 'A major', 'A minor', | |
| 'A# major', 'A# minor', 'B major', 'B minor' | |
| ] | |
| const KEY_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] | |
| function formatTime(seconds) { | |
| if (!seconds || !isFinite(seconds)) return '0:00' | |
| const mins = Math.floor(seconds / 60) | |
| const secs = Math.floor(seconds % 60) | |
| return `${mins}:${secs.toString().padStart(2, '0')}` | |
| } | |
| function ContinuationPlayer({ sessionId }) { | |
| const audioRef = useRef(null) | |
| const rafRef = useRef(null) | |
| const [playing, setPlaying] = useState(false) | |
| const [cTime, setCTime] = useState(0) | |
| const [dur, setDur] = useState(0) | |
| // Stable URL β only changes when sessionId changes, not on every render | |
| const src = useMemo( | |
| () => `/api/stem/${sessionId}/_continuation?processed=false&t=${Date.now()}`, | |
| [sessionId] | |
| ) | |
| // Try to read duration from the audio element (WAV streams may delay reporting it) | |
| const tryReadDuration = useCallback(() => { | |
| const audio = audioRef.current | |
| if (!audio) return | |
| const d = audio.duration | |
| if (d && isFinite(d) && d > 0) { | |
| setDur(d) | |
| } | |
| }, []) | |
| // Smooth animation loop β reads currentTime every frame while playing | |
| useEffect(() => { | |
| if (!playing) { | |
| if (rafRef.current) cancelAnimationFrame(rafRef.current) | |
| return | |
| } | |
| const tick = () => { | |
| if (audioRef.current) { | |
| setCTime(audioRef.current.currentTime) | |
| tryReadDuration() | |
| } | |
| rafRef.current = requestAnimationFrame(tick) | |
| } | |
| rafRef.current = requestAnimationFrame(tick) | |
| return () => { | |
| if (rafRef.current) cancelAnimationFrame(rafRef.current) | |
| } | |
| }, [playing, tryReadDuration]) | |
| const toggle = () => { | |
| if (!audioRef.current) return | |
| if (playing) { | |
| audioRef.current.pause() | |
| } else { | |
| audioRef.current.play() | |
| } | |
| } | |
| const handleSeek = (e) => { | |
| const d = dur || audioRef.current?.duration | |
| if (!audioRef.current || !d || !isFinite(d)) return | |
| const rect = e.currentTarget.getBoundingClientRect() | |
| const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)) | |
| audioRef.current.currentTime = pct * d | |
| setCTime(audioRef.current.currentTime) | |
| } | |
| return ( | |
| <div className="mt-3 bg-green-500/10 border border-green-500/30 rounded-lg p-3"> | |
| <audio | |
| ref={audioRef} | |
| src={src} | |
| preload="auto" | |
| onLoadedMetadata={tryReadDuration} | |
| onDurationChange={tryReadDuration} | |
| onCanPlayThrough={tryReadDuration} | |
| onPlay={() => setPlaying(true)} | |
| onPause={() => setPlaying(false)} | |
| onEnded={() => { setPlaying(false); setCTime(0) }} | |
| /> | |
| <div className="flex items-center gap-3"> | |
| <button | |
| onClick={toggle} | |
| className="w-9 h-9 rounded-full bg-green-500 hover:bg-green-400 flex items-center justify-center transition-colors flex-shrink-0" | |
| > | |
| {playing ? ( | |
| <svg className="w-4 h-4 fill-white" viewBox="0 0 24 24"> | |
| <rect x="6" y="4" width="4" height="16" /> | |
| <rect x="14" y="4" width="4" height="16" /> | |
| </svg> | |
| ) : ( | |
| <svg className="w-4 h-4 fill-white ml-0.5" viewBox="0 0 24 24"> | |
| <polygon points="5,3 19,12 5,21" /> | |
| </svg> | |
| )} | |
| </button> | |
| <div className="flex-1 flex items-center gap-2"> | |
| <span className="text-xs text-gray-400 w-10 text-right font-mono">{formatTime(cTime)}</span> | |
| <div | |
| onClick={handleSeek} | |
| className="flex-1 h-2 bg-gray-700/50 rounded-full cursor-pointer relative" | |
| > | |
| <div | |
| className="h-full bg-green-500 rounded-full" | |
| style={{ width: dur > 0 ? `${(cTime / dur) * 100}%` : '0%' }} | |
| /> | |
| </div> | |
| <span className="text-xs text-gray-400 w-10 font-mono">{formatTime(dur)}</span> | |
| </div> | |
| </div> | |
| <p className="text-xs text-green-400/70 mt-1.5">AI Continuation (seed + generated)</p> | |
| </div> | |
| ) | |
| } | |
| function ControlPanel({ detection, onProcess, isProcessing, hasRegion, isGenerating, onGenerate, sessionId, continuationReady }) { | |
| const [targetKeyWithMode, setTargetKeyWithMode] = useState('C major') | |
| const [targetBpm, setTargetBpm] = useState(120) | |
| const [generationPrompt, setGenerationPrompt] = useState('') | |
| // Initialize targets from detection | |
| useEffect(() => { | |
| if (detection) { | |
| setTargetKeyWithMode(`${detection.key} ${detection.mode}`) | |
| setTargetBpm(detection.bpm) | |
| } | |
| }, [detection]) | |
| const targetKey = targetKeyWithMode.split(' ')[0] | |
| const semitones = useMemo(() => { | |
| if (!detection || !targetKey) return 0 | |
| const fromIdx = KEY_NAMES.indexOf(detection.key) | |
| const toIdx = KEY_NAMES.indexOf(targetKey) | |
| let diff = toIdx - fromIdx | |
| if (diff > 6) diff -= 12 | |
| if (diff < -6) diff += 12 | |
| return diff | |
| }, [detection, targetKey]) | |
| const bpmPercent = useMemo(() => { | |
| if (!detection || !targetBpm) return 0 | |
| return Math.round(((targetBpm - detection.bpm) / detection.bpm) * 100) | |
| }, [detection, targetBpm]) | |
| // Quality indicators per spec | |
| const getKeyQualityBadge = (semitones) => { | |
| const abs = Math.abs(semitones) | |
| if (abs <= 4) return { color: 'bg-green-500', label: 'Recommended' } | |
| if (abs <= 7) return { color: 'bg-yellow-500', label: 'Some quality loss' } | |
| return { color: 'bg-red-500', label: 'Significant quality loss' } | |
| } | |
| const getBpmQualityBadge = (percent) => { | |
| const abs = Math.abs(percent) | |
| if (abs <= 20) return { color: 'bg-green-500', label: 'Recommended' } | |
| if (abs <= 40) return { color: 'bg-yellow-500', label: 'Some quality loss' } | |
| return { color: 'bg-red-500', label: 'Significant quality loss' } | |
| } | |
| const handleKeyShift = useCallback((shift) => { | |
| const currentIdx = KEY_NAMES.indexOf(targetKey) | |
| const newIdx = (currentIdx + shift + 12) % 12 | |
| const mode = targetKeyWithMode.split(' ')[1] | |
| setTargetKeyWithMode(`${KEY_NAMES[newIdx]} ${mode}`) | |
| }, [targetKey, targetKeyWithMode]) | |
| const handleApply = useCallback(() => { | |
| if (!detection) return | |
| const newBpm = Math.abs(targetBpm - detection.bpm) > 0.1 ? targetBpm : null | |
| onProcess(semitones, newBpm) | |
| }, [onProcess, semitones, targetBpm, detection]) | |
| const hasChanges = detection && (semitones !== 0 || Math.abs(targetBpm - detection.bpm) > 0.1) | |
| if (!detection) { | |
| return ( | |
| <div className="glass rounded-xl p-6"> | |
| <h2 className="text-lg font-semibold mb-4 text-white">Controls</h2> | |
| <p className="text-gray-400">Waiting for analysis...</p> | |
| </div> | |
| ) | |
| } | |
| const keyQuality = getKeyQualityBadge(semitones) | |
| const bpmQuality = getBpmQualityBadge(bpmPercent) | |
| const btnBase = 'w-10 h-10 rounded-lg font-bold text-lg transition-all bg-gray-700/60 hover:bg-gray-600/60 text-white border border-gray-600 flex items-center justify-center flex-shrink-0' | |
| const resetBtn = 'text-xs px-3 py-1 rounded bg-white/10 hover:bg-white/20 text-gray-300 transition-colors' | |
| return ( | |
| <div className="glass rounded-xl p-6 animate-fade-in"> | |
| <h2 className="text-lg font-semibold mb-5 text-white">Pitch & Tempo Controls</h2> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> | |
| {/* ββ Key Control ββ */} | |
| <div className="flex flex-col items-center gap-2"> | |
| {/* Label + semitone delta */} | |
| <div className="flex items-center gap-2"> | |
| <span className="text-white font-medium">Key</span> | |
| {semitones !== 0 && ( | |
| <span className="text-gray-400 text-xs font-mono"> | |
| {semitones > 0 ? '+' : ''}{semitones} st | |
| </span> | |
| )} | |
| </div> | |
| {/* β / dropdown / + */} | |
| <div className="flex items-center gap-2 w-full"> | |
| <button onClick={() => handleKeyShift(-1)} className={btnBase}>β</button> | |
| <select | |
| value={targetKeyWithMode} | |
| onChange={(e) => setTargetKeyWithMode(e.target.value)} | |
| className="flex-1 bg-gray-800/80 border border-gray-600 rounded-lg px-3 py-2 text-white text-base text-center focus:outline-none focus:border-purple-500" | |
| > | |
| {ALL_KEYS_WITH_MODES.map(k => ( | |
| <option key={k} value={k}>{k}</option> | |
| ))} | |
| </select> | |
| <button onClick={() => handleKeyShift(1)} className={btnBase}>+</button> | |
| </div> | |
| {/* Quality badge */} | |
| {semitones !== 0 && ( | |
| <span | |
| className={`px-2 py-0.5 rounded text-xs font-medium text-white ${keyQuality.color}`} | |
| title="For best quality, stay within Β±4 semitones of the original key" | |
| > | |
| {keyQuality.label} | |
| </span> | |
| )} | |
| {/* Original + Reset */} | |
| <p className="text-xs text-gray-500">Original: {detection.key} {detection.mode}</p> | |
| <button | |
| onClick={() => setTargetKeyWithMode(`${detection.key} ${detection.mode}`)} | |
| className={resetBtn} | |
| > | |
| Reset | |
| </button> | |
| </div> | |
| {/* ββ BPM Control ββ */} | |
| <div className="flex flex-col items-center gap-2"> | |
| {/* Label + percent delta */} | |
| <div className="flex items-center gap-2"> | |
| <span className="text-white font-medium">BPM</span> | |
| {bpmPercent !== 0 && ( | |
| <span className="text-gray-400 text-xs font-mono"> | |
| {bpmPercent > 0 ? '+' : ''}{bpmPercent}% | |
| </span> | |
| )} | |
| </div> | |
| {/* β / number input / + */} | |
| <div className="flex items-center gap-2 w-full"> | |
| <button onClick={() => setTargetBpm(v => Math.max(20, v - 5))} className={btnBase}>β</button> | |
| <input | |
| type="number" | |
| value={Math.round(targetBpm)} | |
| onChange={(e) => setTargetBpm(parseFloat(e.target.value) || detection.bpm)} | |
| min={Math.round(detection.bpm * 0.5)} | |
| max={Math.round(detection.bpm * 2)} | |
| className="flex-1 bg-gray-800/80 border border-gray-600 rounded-lg px-3 py-2 text-white text-base text-center focus:outline-none focus:border-purple-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" | |
| /> | |
| <button onClick={() => setTargetBpm(v => v + 5)} className={btnBase}>+</button> | |
| </div> | |
| {/* Quality badge */} | |
| {bpmPercent !== 0 && ( | |
| <span | |
| className={`px-2 py-0.5 rounded text-xs font-medium text-white ${bpmQuality.color}`} | |
| title="For best quality, stay within Β±20% of the original BPM" | |
| > | |
| {bpmQuality.label} | |
| </span> | |
| )} | |
| {/* Original + Reset */} | |
| <p className="text-xs text-gray-500">Original: {detection.bpm.toFixed(1)} BPM</p> | |
| <button | |
| onClick={() => setTargetBpm(detection.bpm)} | |
| className={resetBtn} | |
| > | |
| Reset | |
| </button> | |
| </div> | |
| </div> | |
| {/* Apply Changes button */} | |
| <button | |
| onClick={handleApply} | |
| disabled={!hasChanges || isProcessing} | |
| className={`w-full mt-6 py-4 rounded-xl font-semibold text-lg transition-all ${ | |
| !hasChanges || isProcessing | |
| ? 'bg-gray-700 text-gray-500 cursor-not-allowed' | |
| : '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' | |
| }`} | |
| > | |
| {isProcessing ? 'Processing...' : hasChanges ? (hasRegion ? 'Apply to Selection' : 'Apply Changes') : 'No Changes'} | |
| </button> | |
| {/* AI Continuation β hidden | |
| <div className="mt-6 pt-6 border-t border-gray-700/50"> | |
| <h3 className="text-sm font-semibold text-white mb-3">AI Continuation</h3> | |
| <input | |
| type="text" | |
| value={generationPrompt} | |
| onChange={(e) => setGenerationPrompt(e.target.value)} | |
| placeholder="Describe the continuation style (optional)..." | |
| disabled={isGenerating || isProcessing} | |
| 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" | |
| /> | |
| <button | |
| onClick={() => onGenerate(generationPrompt)} | |
| disabled={isGenerating || isProcessing} | |
| className={`w-full py-3 rounded-xl font-semibold text-sm transition-all ${ | |
| isGenerating || isProcessing | |
| ? 'bg-gray-700 text-gray-500 cursor-not-allowed' | |
| : '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' | |
| }`} | |
| > | |
| {isGenerating ? 'Generating...' : 'Generate AI Continuation (15s)'} | |
| </button> | |
| {/* Dedicated continuation player * /} | |
| {continuationReady && sessionId && ( | |
| <ContinuationPlayer sessionId={sessionId} /> | |
| )} | |
| </div> | |
| */} | |
| </div> | |
| ) | |
| } | |
| export default ControlPanel | |