Spaces:
Sleeping
Sleeping
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 Download frontend/src/components/ControlPanel.jsx from MusoraProductDepartment/jam-tracks: direct link, hf CLI and curl.
- Browser
- Download file 13.6 kB
-
https://huggingface.co/spaces/MusoraProductDepartment/jam-tracks/resolve/main/frontend/src/components/ControlPanel.jsx
- Command line
-
hf download hf://spaces/MusoraProductDepartment/jam-tracks/frontend/src/components/ControlPanel.jsx
-
curl -L -o ControlPanel.jsx https://huggingface.co/spaces/MusoraProductDepartment/jam-tracks/resolve/main/frontend/src/components/ControlPanel.jsx
13.6 kB
| 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 | |