jam-tracks / frontend /src /components /ControlPanel.jsx
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