two-row layout: chord+bpm banner / fretboard+progressions side by side
This commit is contained in:
+43
-13
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||||
import AudioCapture from './components/AudioCapture'
|
import AudioCapture from './components/AudioCapture'
|
||||||
import ProgressionBanner from './components/ProgressionBanner'
|
import ProgressionBanner from './components/ProgressionBanner'
|
||||||
|
import ProgressionSuggestions from './components/ProgressionSuggestions'
|
||||||
import Fretboard from './components/Fretboard'
|
import Fretboard from './components/Fretboard'
|
||||||
import Tuner from './components/Tuner'
|
import Tuner from './components/Tuner'
|
||||||
import Piano from './components/Piano'
|
import Piano from './components/Piano'
|
||||||
@@ -25,6 +26,10 @@ export default function App() {
|
|||||||
const [instrument, setInstrument] = useState('guitar') // 'guitar' | 'piano'
|
const [instrument, setInstrument] = useState('guitar') // 'guitar' | 'piano'
|
||||||
const [showTuner, setShowTuner] = useState(false)
|
const [showTuner, setShowTuner] = useState(false)
|
||||||
|
|
||||||
|
// ── BPM estimation from chord-change intervals ────────────────────────────
|
||||||
|
const [bpm, setBpm] = useState(null)
|
||||||
|
const chordTimestampsRef = useRef([])
|
||||||
|
|
||||||
// ── Key: auto-detected + optional lock ───────────────────────────────────
|
// ── Key: auto-detected + optional lock ───────────────────────────────────
|
||||||
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
|
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
|
||||||
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
|
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
|
||||||
@@ -77,12 +82,14 @@ export default function App() {
|
|||||||
pendingKeyRef.current = null
|
pendingKeyRef.current = null
|
||||||
chromaIdxRef.current = 0
|
chromaIdxRef.current = 0
|
||||||
chromaRingRef.current = Array.from({ length: CHROMA_SMOOTH }, () => new Float32Array(12))
|
chromaRingRef.current = Array.from({ length: CHROMA_SMOOTH }, () => new Float32Array(12))
|
||||||
|
chordTimestampsRef.current = []
|
||||||
setKeyInfo(null)
|
setKeyInfo(null)
|
||||||
setLockedKey(null)
|
setLockedKey(null)
|
||||||
effectiveKeyRef.current = null
|
effectiveKeyRef.current = null
|
||||||
setChordHistory([])
|
setChordHistory([])
|
||||||
setDetectedProgression(null)
|
setDetectedProgression(null)
|
||||||
setTopKeyCandidates([])
|
setTopKeyCandidates([])
|
||||||
|
setBpm(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Key lock handlers ─────────────────────────────────────────────────────
|
// ── Key lock handlers ─────────────────────────────────────────────────────
|
||||||
@@ -185,6 +192,22 @@ export default function App() {
|
|||||||
return [...prev.slice(-30), winner]
|
return [...prev.slice(-30), winner]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// BPM: track chord commit timestamps and estimate tempo
|
||||||
|
const now = performance.now()
|
||||||
|
const ts = chordTimestampsRef.current
|
||||||
|
ts.push(now)
|
||||||
|
if (ts.length > 8) ts.shift()
|
||||||
|
if (ts.length >= 3) {
|
||||||
|
const intervals = []
|
||||||
|
for (let i = 1; i < ts.length; i++) intervals.push(ts[i] - ts[i - 1])
|
||||||
|
const avg = intervals.reduce((a, b) => a + b) / intervals.length
|
||||||
|
let estimated = Math.round(60000 / avg)
|
||||||
|
// Chord changes are often every 2 beats — normalise to 55–220 BPM range
|
||||||
|
while (estimated < 55) estimated *= 2
|
||||||
|
while (estimated > 220) estimated /= 2
|
||||||
|
setBpm(estimated)
|
||||||
|
}
|
||||||
|
|
||||||
// Inject chord tones into note history with boost weight so key detection
|
// Inject chord tones into note history with boost weight so key detection
|
||||||
// anchors to confirmed chords rather than transient melody notes
|
// anchors to confirmed chords rather than transient melody notes
|
||||||
const chordPCs = getChordTones(winner)
|
const chordPCs = getChordTones(winner)
|
||||||
@@ -292,11 +315,13 @@ export default function App() {
|
|||||||
keyInfo={effectiveKey}
|
keyInfo={effectiveKey}
|
||||||
detectedProgression={detectedProgression}
|
detectedProgression={detectedProgression}
|
||||||
currentChord={currentChord}
|
currentChord={currentChord}
|
||||||
|
bpm={bpm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Instrument view — select in header ── */}
|
{/* ── Instrument + progressions row ── */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex gap-3 mb-3">
|
||||||
<div>
|
{/* Left: fretboard / piano */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-1 px-1">
|
<div className="flex items-center justify-between mb-1 px-1">
|
||||||
<span className="text-xs text-gray-500 uppercase tracking-widest">Instrument</span>
|
<span className="text-xs text-gray-500 uppercase tracking-widest">Instrument</span>
|
||||||
<select
|
<select
|
||||||
@@ -314,18 +339,23 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Tuner — collapsible ── */}
|
{/* Right: genre progression suggestions */}
|
||||||
<div>
|
<div className="w-56 shrink-0">
|
||||||
<button
|
<ProgressionSuggestions keyInfo={effectiveKey} />
|
||||||
onClick={() => setShowTuner(v => !v)}
|
|
||||||
className="w-full flex items-center justify-between px-4 py-2 bg-panel border border-border rounded-xl text-sm text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-all"
|
|
||||||
>
|
|
||||||
<span>Tuner</span>
|
|
||||||
<span>{showTuner ? '▲' : '▼'}</span>
|
|
||||||
</button>
|
|
||||||
{showTuner && <div className="mt-2"><Tuner /></div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Tuner — collapsible ── */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTuner(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2 bg-panel border border-border rounded-xl text-sm text-gray-400 hover:text-gray-200 hover:border-gray-500 transition-all"
|
||||||
|
>
|
||||||
|
<span>Tuner</span>
|
||||||
|
<span>{showTuner ? '▲' : '▼'}</span>
|
||||||
|
</button>
|
||||||
|
{showTuner && <div className="mt-2"><Tuner /></div>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useRef, useEffect } from 'react'
|
import { useRef, useEffect } from 'react'
|
||||||
import { toRomanNumeral, getSuggestedProgressions } from '../lib/theory'
|
import { toRomanNumeral } from '../lib/theory'
|
||||||
|
|
||||||
const HISTORY_SHOWN = 12
|
const HISTORY_SHOWN = 8
|
||||||
|
|
||||||
function findLoopPosition(chordHistory, progression) {
|
function findLoopPosition(chordHistory, progression) {
|
||||||
if (!progression?.length || !chordHistory.length) return -1
|
if (!progression?.length || !chordHistory.length) return -1
|
||||||
@@ -17,9 +17,8 @@ function findLoopPosition(chordHistory, progression) {
|
|||||||
return progression.indexOf(last)
|
return progression.indexOf(last)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord }) {
|
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord, bpm }) {
|
||||||
const { root, mode, confidence } = keyInfo ?? {}
|
const { root, mode, confidence } = keyInfo ?? {}
|
||||||
const progressions = root ? getSuggestedProgressions(root, mode) : []
|
|
||||||
|
|
||||||
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
const visible = chordHistory.slice(-HISTORY_SHOWN)
|
||||||
const current = visible[visible.length - 1]
|
const current = visible[visible.length - 1]
|
||||||
@@ -39,66 +38,69 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
|||||||
}, [current])
|
}, [current])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-panel border border-border rounded-2xl p-4 mb-3 flex gap-4 min-h-[160px]">
|
<div className="bg-panel border border-border rounded-2xl p-4 mb-3 flex gap-4">
|
||||||
|
|
||||||
{/* ── Left: key + chord history + loop ── */}
|
{/* ── Left: key + chord history + loop ── */}
|
||||||
<div className="flex-1 min-w-0 flex flex-col">
|
<div className="flex-1 min-w-0 flex flex-col gap-2">
|
||||||
|
|
||||||
{/* Key */}
|
{/* Key + history on one row */}
|
||||||
<div className="flex items-baseline gap-2 mb-2">
|
<div className="flex items-end gap-3">
|
||||||
{root ? (
|
<div className="shrink-0 flex items-baseline gap-1.5">
|
||||||
<>
|
{root ? (
|
||||||
<span className="text-2xl font-bold text-accent">{root}</span>
|
<>
|
||||||
<span className="text-gray-400 text-sm">{mode}</span>
|
<span className="text-2xl font-bold text-accent">{root}</span>
|
||||||
{confidence && (
|
<span className="text-gray-400 text-sm">{mode}</span>
|
||||||
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
{confidence && (
|
||||||
)}
|
<span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>
|
||||||
</>
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-600 text-sm">Detecting key…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-px h-6 bg-border shrink-0" />
|
||||||
|
|
||||||
|
{!chordHistory.length ? (
|
||||||
|
<p className="text-gray-600 text-sm">Start listening…</p>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-600 text-sm">Detecting key…</span>
|
<div className="flex items-end gap-1 overflow-x-auto pb-1">
|
||||||
|
{visible.map((chord, i) => {
|
||||||
|
const isCurrent = i === visible.length - 1
|
||||||
|
const age = visible.length - 1 - i
|
||||||
|
const opacity = Math.max(0.25, 1 - age * 0.09)
|
||||||
|
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
ref={isCurrent ? currentRef : null}
|
||||||
|
style={{ opacity }}
|
||||||
|
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 ${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
|
||||||
|
: 'border border-transparent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`font-black leading-none tracking-tight ${
|
||||||
|
isCurrent ? 'text-3xl text-accent' : 'text-xl text-gray-200'
|
||||||
|
}`}>
|
||||||
|
{chord}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs font-semibold mt-0.5 ${
|
||||||
|
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{rn || '\u00A0'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chord history strip */}
|
|
||||||
{!chordHistory.length ? (
|
|
||||||
<p className="text-gray-600 text-sm flex-1 flex items-center">Start listening to detect chords…</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-end gap-1 overflow-x-auto pb-1 flex-1">
|
|
||||||
{visible.map((chord, i) => {
|
|
||||||
const isCurrent = i === visible.length - 1
|
|
||||||
const age = visible.length - 1 - i
|
|
||||||
const opacity = Math.max(0.2, 1 - age * 0.07)
|
|
||||||
const rn = root ? toRomanNumeral(chord, root, mode) : ''
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
ref={isCurrent ? currentRef : null}
|
|
||||||
style={{ opacity }}
|
|
||||||
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 ${
|
|
||||||
isCurrent
|
|
||||||
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
|
|
||||||
: 'border border-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={`font-black leading-none tracking-tight ${
|
|
||||||
isCurrent ? 'text-4xl text-accent' : 'text-2xl text-gray-200'
|
|
||||||
}`}>
|
|
||||||
{chord}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs font-semibold mt-0.5 ${
|
|
||||||
isCurrent ? 'text-amber-400' : 'text-gray-500'
|
|
||||||
}`}>
|
|
||||||
{rn || '\u00A0'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loop */}
|
{/* Loop */}
|
||||||
{detectedProgression && (
|
{detectedProgression && (
|
||||||
<div className="mt-2 flex items-center gap-1.5 flex-wrap">
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
<span className="text-xs text-gray-500">♻</span>
|
<span className="text-xs text-gray-500">♻</span>
|
||||||
{detectedProgression.map((chord, i) => {
|
{detectedProgression.map((chord, i) => {
|
||||||
const isActive = i === loopPos
|
const isActive = i === loopPos
|
||||||
@@ -106,7 +108,7 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`flex flex-col items-center px-2 py-1 rounded-lg border transition-all duration-200 ${
|
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)]'
|
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)]'
|
||||||
: 'bg-border border-border'
|
: 'bg-border border-border'
|
||||||
@@ -115,9 +117,7 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
|||||||
<span className={`text-sm font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-300'}`}>
|
<span className={`text-sm font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-300'}`}>
|
||||||
{chord}
|
{chord}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-xs ${isActive ? 'text-amber-400' : 'text-gray-600'}`}>
|
<span className={`text-xs ${isActive ? 'text-amber-400' : 'text-gray-600'}`}>{rn}</span>
|
||||||
{rn}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -129,44 +129,21 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
|
|||||||
{/* ── Divider ── */}
|
{/* ── Divider ── */}
|
||||||
<div className="w-px bg-border shrink-0" />
|
<div className="w-px bg-border shrink-0" />
|
||||||
|
|
||||||
{/* ── Right: current chord + genre progressions ── */}
|
{/* ── Right: big chord + BPM ── */}
|
||||||
<div className="w-64 shrink-0 flex flex-col">
|
<div className="w-36 shrink-0 flex flex-col items-center justify-center gap-1">
|
||||||
{current ? (
|
{current ? (
|
||||||
<>
|
<>
|
||||||
{/* Big chord */}
|
<div className="text-6xl font-black text-amber-400 leading-none">{current}</div>
|
||||||
<div className="text-center mb-3">
|
<div className="text-sm text-gray-500">{currentRN}</div>
|
||||||
<div className="text-6xl font-black text-amber-400 leading-none">{current}</div>
|
{bpm && (
|
||||||
<div className="text-sm text-gray-500 mt-1">{currentRN}</div>
|
<div className="mt-2 text-center">
|
||||||
</div>
|
<div className="text-2xl font-bold text-gray-300">{bpm}</div>
|
||||||
|
<div className="text-xs text-gray-600 uppercase tracking-widest">BPM ~</div>
|
||||||
{/* Genre progressions */}
|
</div>
|
||||||
<div className="space-y-1.5 overflow-y-auto flex-1">
|
)}
|
||||||
{progressions.map(prog => (
|
|
||||||
<div key={prog.genre} className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-gray-600 w-10 shrink-0">{prog.genre}</span>
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{prog.chords.map((chord, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={`px-1.5 py-0.5 rounded text-xs font-medium transition-colors ${
|
|
||||||
chord === current
|
|
||||||
? 'bg-accent/30 text-accent border border-accent/50'
|
|
||||||
: 'bg-border text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{chord}
|
|
||||||
<span className="ml-0.5 text-gray-600 opacity-60">({prog.rn[i]})</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-center flex-1">
|
<p className="text-gray-600 text-xs text-center">Play a chord</p>
|
||||||
<p className="text-gray-600 text-sm text-center">Play a chord to see progressions</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user