edu panel

This commit is contained in:
vadimwit
2026-03-18 01:26:51 +00:00
parent 4a45e82abe
commit 8cd9b01941
11 changed files with 2888 additions and 10 deletions
+23
View File
@@ -10,6 +10,9 @@ import Settings from './components/Settings'
import DebugView from './components/DebugView'
import DrumView from './components/DrumView'
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
import ChordDetailModal from './components/ChordDetailModal'
import ExplorePanel from './components/ExplorePanel'
import CurrentJamPanel from './components/CurrentJamPanel'
import settingIcon from './assets/setting-icon.png'
const DEFAULTS = {
@@ -92,6 +95,7 @@ export default function App() {
// ── Chord state ───────────────────────────────────────────────────────────────
const [chordHistory, setChordHistory] = useState([])
const [detectedProgression, setDetectedProgression] = useState(null)
const [selectedChord, setSelectedChord] = useState(null)
// ── Top key candidates (shown as quick-lock chips) ────────────────────────────
const [topKeyCandidates, setTopKeyCandidates] = useState([])
@@ -530,12 +534,16 @@ export default function App() {
</div>
)}
{/* ── Chord detail modal ── */}
<ChordDetailModal chord={selectedChord} onClose={() => setSelectedChord(null)} keyInfo={effectiveKey} chordHistory={chordHistory} />
{/* ── Progression banner ── */}
<ProgressionBanner
chordHistory={chordHistory}
keyInfo={effectiveKey}
detectedProgression={detectedProgression}
currentChord={currentChord}
onChordClick={setSelectedChord}
/>
{/* ── Instrument + progressions row ── */}
@@ -553,6 +561,21 @@ export default function App() {
</div>
</div>
{/* ── Explore any chord — collapsible ── */}
<ExplorePanel
keyInfo={effectiveKey}
chordHistory={chordHistory}
onChordClick={setSelectedChord}
/>
{/* ── Current jam — collapsible ── */}
<CurrentJamPanel
keyInfo={effectiveKey}
chordHistory={chordHistory}
detectedProgression={detectedProgression}
onChordClick={setSelectedChord}
/>
{/* ── Behind the scenes — collapsible ── */}
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button
+137
View File
@@ -0,0 +1,137 @@
// SVG chord diagram — 6 strings × 5 visible frets
// Props:
// frets[] — [s6…s1]: fret number or 'x' (muted)
// fingers[] — [s6…s1]: finger 1-4, 0 = open/barre indicator
// barre — { fret, fromStr, toStr } or null
// baseFret — which fret number is at the top of the diagram (1 = standard)
// label — caption below the box
const STRINGS = 6
const ROWS = 5 // visible frets
const SX = 32 // left margin (open/mute indicators)
const SY = 28 // top margin (nut / baseFret label)
const GX = 26 // gap between strings
const GY = 22 // gap between frets
const DOT_R = 9 // dot radius
const W = SX + GX * (STRINGS - 1) + 24 // total width
const H = SY + GY * ROWS + 20 // total height
function strX(s) { return SX + (STRINGS - 1 - s) * GX } // s=0 is s6 (low E, leftmost)
function fretY(f) { return SY + f * GY } // f=0 is above first fret, f=1…5 are fret centers
export default function ChordBox({ frets, fingers, barre, baseFret = 1, label }) {
const isOpen = baseFret === 1
// Map fret numbers to diagram row (0-indexed from top)
function toRow(absF) {
return absF - baseFret + 1 // fret at baseFret → row 1 (center of first fret)
}
// Barre bar: draw a rounded rect across strings
function renderBarre() {
if (!barre) return null
const row = toRow(barre.fret)
if (row < 1 || row > ROWS) return null
const x1 = strX(STRINGS - barre.toStr) // toStr is highest string number = leftmost
const x2 = strX(STRINGS - barre.fromStr) // fromStr is lowest string number = rightmost
const cy = fretY(row) - GY / 2
return (
<rect
key="barre"
x={x1 - DOT_R}
y={cy - DOT_R}
width={x2 - x1 + DOT_R * 2}
height={DOT_R * 2}
rx={DOT_R}
fill="#a855f7"
opacity={0.9}
/>
)
}
return (
<div className="flex flex-col items-center gap-1">
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} className="overflow-visible">
{/* ── Nut or baseFret indicator ── */}
{isOpen ? (
<rect x={SX - 2} y={SY - 4} width={GX * (STRINGS - 1) + 4} height={4} rx={2} fill="#e5e7eb" />
) : (
<text x={SX - 6} y={SY + GY * 0.5} textAnchor="end" dominantBaseline="middle"
fill="#9ca3af" fontSize={10} fontFamily="monospace">
{baseFret}
</text>
)}
{/* ── Fret lines ── */}
{Array.from({ length: ROWS + 1 }, (_, i) => (
<line key={`fl${i}`}
x1={SX} y1={fretY(i) - GY / 2}
x2={SX + GX * (STRINGS - 1)} y2={fretY(i) - GY / 2}
stroke="#374151" strokeWidth={i === 0 && isOpen ? 3 : 1}
/>
))}
{/* ── String lines ── */}
{Array.from({ length: STRINGS }, (_, s) => (
<line key={`sl${s}`}
x1={strX(s)} y1={SY - GY / 2}
x2={strX(s)} y2={fretY(ROWS) - GY / 2}
stroke="#4b5563" strokeWidth={1}
/>
))}
{/* ── Barre ── */}
{renderBarre()}
{/* ── Dots + open/mute indicators ── */}
{frets.map((f, s) => {
const cx = strX(STRINGS - 1 - s)
if (f === 'x') {
return (
<text key={`m${s}`} x={cx} y={SY - GY / 2 - 7}
textAnchor="middle" fill="#6b7280" fontSize={12} fontWeight="bold">
×
</text>
)
}
if (f === 0) {
return (
<circle key={`o${s}`} cx={cx} cy={SY - GY / 2 - 7}
r={5} fill="none" stroke="#6b7280" strokeWidth={1.5} />
)
}
const row = toRow(f)
if (row < 1 || row > ROWS) return null
const cy = fretY(row) - GY / 2
const finger = fingers?.[s] ?? 0
return (
<g key={`d${s}`}>
<circle cx={cx} cy={cy} r={DOT_R} fill="#a855f7" />
{finger > 0 && (
<text x={cx} y={cy} textAnchor="middle" dominantBaseline="middle"
fill="white" fontSize={9} fontWeight="bold">
{finger}
</text>
)}
</g>
)
})}
{/* ── String name labels ── */}
{['e','B','G','D','A','E'].map((n, i) => (
<text key={`sn${i}`}
x={strX(i)} y={H - 4}
textAnchor="middle" fill="#4b5563" fontSize={8}>
{n}
</text>
))}
</svg>
{label && (
<p className="text-[11px] text-gray-400 text-center leading-tight max-w-[120px]">{label}</p>
)}
</div>
)
}
+467
View File
@@ -0,0 +1,467 @@
import { useState, useEffect } from 'react'
import ChordBox from './ChordBox'
import MiniPiano from './MiniPiano'
import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings'
import { CHORD_TYPES, NOTES, getChordsInKey, toRomanNumeral, getSuggestedProgressions } from '../lib/theory'
import { FAMOUS_PROGRESSIONS, progressionInKey, parseChord as parseChordEd } from '../lib/education'
const CHORD_SUFFIX_OPTIONS = [
{ key: 'maj', label: 'Major' },
{ key: 'min', label: 'Minor' },
{ key: 'dom7', label: '7' },
{ key: 'maj7', label: 'maj7' },
{ key: 'min7', label: 'm7' },
{ key: 'dim', label: 'dim' },
{ key: 'dim7', label: 'dim7' },
{ key: 'half_dim', label: 'm7♭5' },
{ key: 'aug', label: 'aug' },
{ key: 'sus4', label: 'sus4' },
{ key: 'sus2', label: 'sus2' },
{ key: 'maj6', label: '6' },
{ key: 'min6', label: 'm6' },
{ key: 'add9', label: 'add9' },
]
function chordDisplayName(root, typeKey) {
const type = CHORD_TYPES[typeKey]
if (!type) return root
return root + type.suffix
}
function GuitarTab({ chordName }) {
const voicings = getGuitarVoicings(chordName)
if (!voicings.length) {
return <p className="text-gray-500 text-sm text-center py-8">No guitar voicings found for {chordName}.</p>
}
return (
<div>
<p className="text-xs text-gray-500 mb-4">
Click any voicing to learn it. Purple = chord tones. Finger numbers inside dots (1=index, 4=pinky).
Barre chords show the fret number on the left.
</p>
<div className="flex flex-wrap gap-6 justify-start">
{voicings.map((v, i) => (
<div key={i} className="flex flex-col items-center gap-1 p-3 rounded-xl bg-surface border border-border hover:border-accent/40 transition-colors">
<ChordBox
frets={v.frets}
fingers={v.fingers}
barre={v.barre}
baseFret={v.baseFret}
/>
<p className="text-[11px] text-gray-400 text-center mt-1 max-w-[120px] leading-tight">{v.label}</p>
</div>
))}
</div>
<div className="mt-4 p-3 rounded-lg bg-surface border border-border">
<p className="text-xs text-gray-500">
<span className="text-accent font-semibold">Pro tip:</span> Learn the E-shape and A-shape barres first
they cover all 12 roots. Then add open voicings for the keys you play in most.
High-fret voicings (above fret 7) work great as jazz comping shapes in a band mix.
</p>
</div>
</div>
)
}
function PianoTab({ chordName }) {
const parsed = parseChord(chordName)
const techniques = getPianoTechniques(chordName)
const rootPc = parsed?.rootPc ?? 0
if (!techniques.length) {
return <p className="text-gray-500 text-sm text-center py-8">No piano techniques for {chordName}.</p>
}
return (
<div className="flex flex-col gap-4">
<p className="text-xs text-gray-500">
<span className="text-blue-400 font-semibold">Blue = Left hand</span> &nbsp;·&nbsp;
<span className="text-accent font-semibold">Purple = Right hand</span> &nbsp;·&nbsp;
R marks the root.
</p>
{techniques.map((t, i) => (
<div key={i} className="p-4 rounded-xl bg-surface border border-border hover:border-accent/30 transition-colors">
<div className="flex flex-col lg:flex-row gap-4 items-start">
<div className="shrink-0 overflow-x-auto">
<MiniPiano rootPc={rootPc} lh={t.lh} rh={t.rh} />
</div>
<div className="flex flex-col gap-1.5 min-w-0">
<h3 className="font-bold text-white text-sm">{t.name}</h3>
<p className="text-gray-400 text-xs">{t.desc}</p>
<p className="text-xs text-amber-400/80 mt-1">
<span className="text-amber-400 font-semibold">Tip:</span> {t.tip}
</p>
<div className="flex gap-3 mt-1 text-xs text-gray-600">
{t.lh.length > 0 && (
<span className="text-blue-400">LH: {t.lh.map(iv => {
const n = NOTES[(rootPc + iv) % 12]
return iv === 0 ? `${n} (root)` : n
}).join(', ')}</span>
)}
{t.rh.length > 0 && (
<span className="text-accent">RH: {t.rh.map(iv => {
const n = NOTES[(rootPc + iv) % 12]
return n
}).join(', ')}</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
)
}
function ChordQuickPick({ label, chords, active, keyInfo, onSelect }) {
if (!chords.length) return null
return (
<div className="flex flex-wrap items-center gap-2">
<span className="text-[11px] text-gray-600 uppercase tracking-wider shrink-0 w-20">{label}</span>
<div className="flex flex-wrap gap-1.5">
{chords.map(chord => {
const rn = keyInfo?.root ? toRomanNumeral(chord, keyInfo.root, keyInfo.mode) : ''
return (
<button key={chord}
onClick={() => onSelect(chord)}
className={`flex flex-col items-center px-2.5 py-1 rounded-lg border text-xs font-bold transition-all ${
active === chord
? 'bg-accent border-accent text-white'
: 'bg-surface border-border text-gray-300 hover:border-accent/50 hover:text-white'
}`}>
<span>{chord}</span>
{rn && <span className="text-[9px] font-normal opacity-60 leading-none">{rn}</span>}
</button>
)
})}
</div>
</div>
)
}
// ─── Progressions sub-tab ─────────────────────────────────────────────────────
// Determine whether a chord type is major-ish or minor-ish for matching
const MAJOR_TYPES = new Set(['maj','maj7','maj6','add9','sus4','sus2','aug','dom7'])
const MINOR_TYPES = new Set(['min','min7','min6','half_dim','dim','dim7'])
function isMajorType(t) { return MAJOR_TYPES.has(t) }
function isMinorType(t) { return MINOR_TYPES.has(t) }
function ProgressionsSubTab({ chordName, onChordClick }) {
const parsed = parseChord(chordName)
if (!parsed) return null
const { rootPc, type } = parsed
const root = NOTES[rootPc]
// Famous progressions where this chord can be the tonic (degree 0)
const isMajor = isMajorType(type)
const isMinor = isMinorType(type)
const tonicProgs = FAMOUS_PROGRESSIONS.filter(p => {
const q0 = p.qualities[0]
if (isMajor && isMajorType(q0)) return true
if (isMinor && isMinorType(q0)) return true
return false
})
// Genre-based suggestions from theory.js
const genreProgs = getSuggestedProgressions(root, isMajor ? 'major' : 'minor')
// Roles this chord plays in other keys
const ROLES = []
for (let keyPc = 0; keyPc < 12; keyPc++) {
for (const mode of ['major', 'minor']) {
const diatonicChords = getChordsInKey(NOTES[keyPc], mode)
const idx = diatonicChords.indexOf(chordName)
if (idx !== -1) {
const rn = toRomanNumeral(chordName, NOTES[keyPc], mode)
ROLES.push({ keyRoot: NOTES[keyPc], mode, rn, diatonicChords })
break
}
}
}
return (
<div className="flex flex-col gap-5">
{/* ── Famous progressions starting from this chord ── */}
<div>
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">
Famous progressions {chordName} as tonic
</p>
{tonicProgs.length === 0 && (
<p className="text-gray-600 text-sm">No exact matches try a major or minor chord.</p>
)}
<div className="flex flex-col gap-3">
{tonicProgs.slice(0, 6).map(prog => {
const chordsHere = progressionInKey(prog, root)
return (
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
<div className="flex items-center gap-2 flex-wrap mb-2">
<span className="font-bold text-white text-sm">{prog.name}</span>
<span className="text-[10px] font-mono text-gray-500">{prog.pattern}</span>
{prog.genre.map(g => (
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
))}
</div>
{/* Chord sequence */}
<div className="flex flex-wrap gap-1.5 items-center mb-2">
{chordsHere.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<button
onClick={() => onChordClick?.(c)}
className={`px-2.5 py-1 rounded-lg font-bold text-sm border transition-all ${
i === 0
? 'bg-accent border-accent text-white'
: 'bg-panel border-border text-gray-200 hover:border-accent/50 hover:text-accent'
}`}
title={`Voicings for ${c}`}
>
{c}
</button>
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs"></span>}
</span>
))}
</div>
<p className="text-xs text-gray-500 leading-snug">{prog.description}</p>
{prog.songs[0] && (
<p className="text-[11px] text-gray-600 mt-1">e.g. {prog.songs.slice(0, 3).join(' · ')}</p>
)}
</div>
)
})}
</div>
</div>
{/* ── Genre-based next-chord suggestions ── */}
{genreProgs.length > 0 && (
<div>
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">
Genre suggestions starting from {chordName}
</p>
<div className="flex flex-col gap-2">
{genreProgs.slice(0, 6).map((prog, pi) => (
<div key={pi} className="flex items-center gap-2 p-2 bg-surface border border-border rounded-lg flex-wrap">
<span className="text-[10px] font-bold text-gray-500 w-14 shrink-0">{prog.genre}</span>
<div className="flex gap-1.5 flex-wrap items-center">
{prog.chords.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<button
onClick={() => onChordClick?.(c)}
className="px-2 py-0.5 bg-panel border border-border hover:border-accent/50 rounded text-xs font-bold text-gray-200 hover:text-accent transition-all"
title={`Voicings for ${c}`}
>
{c}
</button>
{i < prog.chords.length - 1 && <span className="text-gray-700 text-[10px]"></span>}
</span>
))}
</div>
<span className="text-[10px] text-gray-600 font-mono ml-1">{prog.rn?.join(' ')}</span>
</div>
))}
</div>
</div>
)}
{/* ── Roles this chord plays ── */}
{ROLES.length > 0 && (
<div>
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">
{chordName} appears in these keys
</p>
<div className="flex flex-wrap gap-2">
{ROLES.slice(0, 8).map(({ keyRoot, mode, rn, diatonicChords }) => (
<div key={`${keyRoot}-${mode}`}
className="px-3 py-2 bg-surface border border-border rounded-xl text-xs flex flex-col gap-1">
<div className="flex items-center gap-1.5">
<span className="font-bold text-white">{keyRoot}</span>
<span className="text-gray-500 capitalize">{mode}</span>
<span className="text-amber-400 font-bold">{rn}</span>
</div>
<div className="flex gap-1 flex-wrap">
{diatonicChords.map((c, i) => (
<button key={i}
onClick={() => onChordClick?.(c)}
className={`px-1.5 py-0.5 rounded text-[10px] font-bold transition-all ${
c === chordName
? 'bg-accent text-white'
: 'text-gray-500 hover:text-gray-300'
}`}>
{c}
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
// ─── Explore tab ──────────────────────────────────────────────────────────────
function ExploreTab({ initialChord, keyInfo, chordHistory }) {
const parsed = parseChord(initialChord)
const [root, setRoot] = useState(parsed ? NOTES[parsed.rootPc] : 'C')
const [typeKey, setTypeKey] = useState(parsed?.type ?? 'maj')
const [subTab, setSubTab] = useState('guitar')
const [active, setActive] = useState(initialChord ?? '')
const chordName = chordDisplayName(root, typeKey)
function selectChord(chord) {
setActive(chord)
const p = parseChord(chord)
if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type) }
}
const recentChords = [...new Set([...(chordHistory ?? [])].reverse())].slice(0, 12)
const keyChords = keyInfo?.root ? getChordsInKey(keyInfo.root, keyInfo.mode ?? 'major') : []
return (
<div className="flex flex-col gap-4">
{/* ── Contextual quick-picks ── */}
{(recentChords.length > 0 || keyChords.length > 0) && (
<div className="flex flex-col gap-3 p-3 bg-surface border border-border rounded-xl">
<ChordQuickPick label="History" chords={recentChords} active={active} keyInfo={keyInfo} onSelect={selectChord} />
{keyChords.length > 0 && (
<>
{recentChords.length > 0 && <div className="h-px bg-border" />}
<ChordQuickPick
label={`${keyInfo.root} ${keyInfo.mode ?? ''}`}
chords={keyChords} active={active} keyInfo={keyInfo} onSelect={selectChord}
/>
</>
)}
</div>
)}
{/* ── Manual picker ── */}
<div className="flex flex-wrap gap-2 items-center p-3 bg-surface border border-border rounded-xl">
<span className="text-xs text-gray-500 shrink-0">Root:</span>
<div className="flex flex-wrap gap-1">
{NOTES.map(n => (
<button key={n}
onClick={() => { setRoot(n); setActive('') }}
className={`px-2 py-0.5 rounded text-xs font-bold transition-all ${
root === n ? 'bg-accent text-white' : 'bg-border text-gray-400 hover:text-white'
}`}>
{n}
</button>
))}
</div>
<div className="w-px h-4 bg-border shrink-0" />
<span className="text-xs text-gray-500 shrink-0">Type:</span>
<div className="relative">
<select
value={typeKey}
onChange={e => { setTypeKey(e.target.value); setActive('') }}
className="appearance-none bg-panel border border-border rounded-lg pl-2 pr-6 py-1 text-xs text-gray-200 cursor-pointer focus:outline-none focus:border-accent"
>
{CHORD_SUFFIX_OPTIONS.map(o => (
<option key={o.key} value={o.key}>{o.label}</option>
))}
</select>
<span className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span>
</div>
<div className="ml-auto text-xl font-black text-accent">{chordName}</div>
</div>
{/* ── Sub-tabs ── */}
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
{[
{ key: 'guitar', label: '🎸 Guitar' },
{ key: 'piano', label: '🎹 Piano' },
{ key: 'progressions', label: '🎵 Progressions' },
].map(t => (
<button key={t.key}
onClick={() => setSubTab(t.key)}
className={`px-4 py-1.5 rounded-lg text-sm font-semibold transition-all whitespace-nowrap ${
subTab === t.key ? 'bg-accent text-white' : 'text-gray-400 hover:text-white'
}`}>
{t.label}
</button>
))}
</div>
{subTab === 'guitar' && <GuitarTab chordName={chordName} />}
{subTab === 'piano' && <PianoTab chordName={chordName} />}
{subTab === 'progressions' && <ProgressionsSubTab chordName={chordName} onChordClick={c => selectChord(c)} />}
</div>
)
}
// ─── Main modal ───────────────────────────────────────────────────────────────
export default function ChordDetailModal({ chord, onClose, keyInfo, chordHistory }) {
const [tab, setTab] = useState('guitar')
// Reset tab when chord changes
useEffect(() => { setTab('guitar') }, [chord])
// Close on Escape
useEffect(() => {
function onKey(e) { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
if (!chord) return null
const parsed = parseChord(chord)
const typeName = parsed ? (CHORD_SUFFIX_OPTIONS.find(o => o.key === parsed.type)?.label ?? parsed.type) : ''
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 backdrop-blur-sm p-4 overflow-y-auto"
onClick={e => { if (e.target === e.currentTarget) onClose() }}
>
<div className="w-full max-w-3xl bg-panel border border-border rounded-2xl shadow-2xl mt-8 mb-8">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div>
<h2 className="text-3xl font-black text-accent leading-none">{chord}</h2>
<p className="text-xs text-gray-500 mt-0.5">{typeName} chord · tap a voicing to study it</p>
</div>
<button
onClick={onClose}
className="p-2 text-gray-500 hover:text-white transition-colors text-xl leading-none"
aria-label="Close"
>
×
</button>
</div>
{/* Tab bar */}
<div className="flex gap-1 px-6 pt-4">
{[
{ key: 'guitar', label: '🎸 Guitar Voicings' },
{ key: 'piano', label: '🎹 Piano Techniques' },
{ key: 'explore', label: '🔍 Explore Any Chord' },
].map(t => (
<button key={t.key}
onClick={() => setTab(t.key)}
className={`px-4 py-2 rounded-t-xl text-sm font-semibold transition-all border-b-2 ${
tab === t.key
? 'text-accent border-accent bg-accent/10'
: 'text-gray-500 border-transparent hover:text-gray-300'
}`}>
{t.label}
</button>
))}
</div>
{/* Content */}
<div className="px-6 py-5">
{tab === 'guitar' && <GuitarTab chordName={chord} />}
{tab === 'piano' && <PianoTab chordName={chord} />}
{tab === 'explore' && <ExploreTab initialChord={chord} keyInfo={keyInfo} chordHistory={chordHistory} />}
</div>
</div>
</div>
)
}
+367
View File
@@ -0,0 +1,367 @@
import { useState } from 'react'
import ChordBox from './ChordBox'
import RiffDiagram from './RiffDiagram'
import { getGuitarVoicings, parseChord } from '../lib/voicings'
import { CHORD_TYPES, NOTES, toRomanNumeral } from '../lib/theory'
import { findSimilarProgressions, progressionInKey } from '../lib/education'
// ─── Scale ideas per mode ─────────────────────────────────────────────────────
const SCALE_IDEAS = {
major: [
{ name: 'Major Pentatonic', intervals: '12356', scaleIntervals: [0,2,4,7,9], desc: 'Safe and bright. Everything you play will land. Start on the root, end on the root.' },
{ name: 'Mixolydian', intervals: '123456–♭7', scaleIntervals: [0,2,4,5,7,9,10], desc: 'Major with a bluesy ♭7. The defining sound of classic rock — Sweet Home Alabama lives here.' },
{ name: 'Lydian', intervals: '123–♯4567', scaleIntervals: [0,2,4,6,7,9,11], desc: 'Dreamy and floating. The ♯4 is the magic note — use it on long sustained notes for instant wonder.' },
],
minor: [
{ name: 'Minor Pentatonic', intervals: '1–♭345–♭7', scaleIntervals: [0,3,5,7,10], desc: 'The blues box. Bends on ♭3 and slides to 5 are gold. Start here every time.' },
{ name: 'Natural Minor', intervals: '12–♭345–♭6–♭7', scaleIntervals: [0,2,3,5,7,8,10], desc: 'Full Aeolian scale. Melodic and dark. The ♭6 gives it a cinematic quality.' },
{ name: 'Dorian', intervals: '12–♭3456–♭7', scaleIntervals: [0,2,3,5,7,9,10], desc: "Minor with a raised 6th — smooth and soulful. Santana's go-to. That major 6th is everything." },
],
dorian: [
{ name: 'Dorian Mode', intervals: '12–♭3456–♭7', scaleIntervals: [0,2,3,5,7,9,10], desc: "The raised 6th over minor is the colour. Mix freely with minor pentatonic and touch that 6th note." },
{ name: 'Minor Pentatonic', intervals: '1–♭345–♭7', scaleIntervals: [0,3,5,7,10], desc: 'Safe backbone in Dorian. You can ignore the 6th — or highlight it for that Dorian sparkle.' },
{ name: 'Blues Scale', intervals: '1–♭34–♭55–♭7', scaleIntervals: [0,3,5,6,7,10], desc: 'Add the ♭5 passing tone through the 5 — that slide is the essence of blues expression.' },
],
mixolydian: [
{ name: 'Mixolydian Mode', intervals: '123456–♭7', scaleIntervals: [0,2,4,5,7,9,10], desc: 'The ♭7 is your signature note. Hit it and slide down — instant swagger.' },
{ name: 'Major Pentatonic', intervals: '12356', scaleIntervals: [0,2,4,7,9], desc: 'Works beautifully over the I chord. Clean and reliable when you need to land safely.' },
{ name: 'Blues Scale', intervals: '1–♭3345–♭7', scaleIntervals: [0,3,4,5,7,10], desc: 'The hybrid blues scale. Bend the ♭3 up to the 3 — that tension and release is everything.' },
],
phrygian: [
{ name: 'Phrygian Mode', intervals: '1–♭2–♭345–♭6–♭7', scaleIntervals: [0,1,3,5,7,8,10], desc: 'That ♭2 is the spine-chilling note. Lean on it. Spanish fire and metal darkness in one scale.' },
{ name: 'Phrygian Dominant',intervals: '1–♭2345–♭6–♭7', scaleIntervals: [0,1,4,5,7,8,10], desc: 'Raise the ♭3 to a major 3rd. Flamenco and Middle-Eastern intensity. Dramatic every time.' },
{ name: 'Minor Pentatonic', intervals: '1–♭345–♭7', scaleIntervals: [0,3,5,7,10], desc: 'Avoid the ♭2 and play safe pentatonic runs — then hit the ♭2 as a surprise.' },
],
lydian: [
{ name: 'Lydian Mode', intervals: '123–♯4567', scaleIntervals: [0,2,4,6,7,9,11], desc: 'Float on the ♯4. John Williams writes entire film scores in Lydian. Sustain everything.' },
{ name: 'Major Pentatonic', intervals: '12356', scaleIntervals: [0,2,4,7,9], desc: 'The reliable base. Use Lydian mode sparingly on top for colour.' },
{ name: 'Lydian Dominant', intervals: '123–♯456–♭7', scaleIntervals: [0,2,4,6,7,9,10], desc: 'Lydian with a ♭7 — the jazz/fusion ♯4 chord sound. Herbie Hancock territory.' },
],
}
// Fallback
const SCALE_FALLBACK = SCALE_IDEAS.major
// ─── Style variations for a progression ──────────────────────────────────────
const STYLE_VARIATIONS = [
{
key: 'open',
label: 'Open & Spacious',
desc: 'Sus2 and add9 voicings — airy, gentle. Great for quiet intros and ambient sections.',
typeMap: { maj: 'sus2', min: 'sus2', dom7: 'sus4', maj7: 'add9', min7: 'sus2', add9: 'sus2', sus4: 'sus4', sus2: 'sus2', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'sus2', min6: 'sus2' },
color: 'text-blue-400',
border: 'border-blue-900/40',
},
{
key: 'jazz',
label: 'Jazz Upgrade',
desc: 'Triads → 7ths — instant sophistication. Works at any tempo, in any band context.',
typeMap: { maj: 'maj7', min: 'min7', dom7: 'dom7', add9: 'maj7', sus2: 'sus2', sus4: 'sus4', dim: 'dim7', aug: 'aug', half_dim: 'half_dim', maj6: 'maj6', min6: 'min6' },
color: 'text-amber-400',
border: 'border-amber-900/40',
},
{
key: 'blues',
label: 'Blues Stomp',
desc: 'Everything → dom7. Gritty, raw, powerful. All three chords want to slide and bend.',
typeMap: { maj: 'dom7', min: 'dom7', maj7: 'dom7', min7: 'dom7', add9: 'dom7', sus2: 'dom7', sus4: 'dom7', dim: 'dim7', aug: 'aug', half_dim: 'dom7', maj6: 'dom7', min6: 'dom7' },
color: 'text-red-400',
border: 'border-red-900/40',
},
{
key: 'modern',
label: 'Neo-Soul / Modern',
desc: "Add9 on majors, m7 on minors. D'Angelo, Thundercat, Childish Gambino territory.",
typeMap: { maj: 'add9', min: 'min7', dom7: 'dom7', maj7: 'add9', min7: 'min7', add9: 'add9', sus2: 'sus2', sus4: 'sus4', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'add9', min6: 'min7' },
color: 'text-purple-400',
border: 'border-purple-900/40',
},
]
// Transform a chord via a type map
function transformChord(chordStr, typeMap) {
const p = parseChord(chordStr)
if (!p) return chordStr
const newType = typeMap[p.type] ?? p.type
return NOTES[p.rootPc] + (CHORD_TYPES[newType]?.suffix ?? '')
}
// Get best voicings for a chord — prefer open shapes, then low-fret barre
function getBestVoicings(chordStr, max = 4) {
const all = getGuitarVoicings(chordStr)
// Sort: open shapes first (label contains "Open"), then barre
const open = all.filter(v => v.label.includes('Open'))
const barre = all.filter(v => !v.label.includes('Open'))
return [...open, ...barre].slice(0, max)
}
// ─── Per-chord voicing strip ──────────────────────────────────────────────────
function ChordStrip({ chordStr, keyInfo, onChordClick }) {
const voicings = getBestVoicings(chordStr, 4)
const rn = keyInfo?.root ? toRomanNumeral(chordStr, keyInfo.root, keyInfo.mode) : ''
return (
<div className="flex flex-col gap-2 p-3 bg-surface border border-border rounded-xl">
<div className="flex items-center gap-2">
<button onClick={() => onChordClick?.(chordStr)}
className="px-3 py-1 bg-accent/10 border border-accent/40 rounded-lg font-black text-lg text-accent hover:bg-accent/20 transition-colors">
{chordStr}
</button>
{rn && <span className="text-amber-400 text-sm font-semibold">{rn}</span>}
<span className="text-[11px] text-gray-600 ml-auto">click for all voicings</span>
</div>
{voicings.length > 0 ? (
<div className="flex flex-wrap gap-3">
{voicings.map((v, i) => (
<div key={i} className="flex flex-col items-center">
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
<p className="text-[10px] text-gray-600 text-center mt-1 max-w-[100px]">{v.label}</p>
</div>
))}
</div>
) : (
<p className="text-gray-600 text-xs">No voicings available.</p>
)}
</div>
)
}
// ─── Style variation section ──────────────────────────────────────────────────
function StyleSection({ progression, onChordClick }) {
const [expanded, setExpanded] = useState(null)
return (
<div className="flex flex-col gap-2">
{STYLE_VARIATIONS.map(style => {
const isOpen = expanded === style.key
const transformed = progression.map(c => transformChord(c, style.typeMap))
return (
<div key={style.key} className={`border rounded-xl overflow-hidden transition-colors ${style.border} hover:border-opacity-70`}>
<button onClick={() => setExpanded(isOpen ? null : style.key)}
className="w-full flex items-center justify-between px-4 py-3 text-left">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className={`font-bold text-sm ${style.color}`}>{style.label}</span>
<div className="flex gap-1">
{transformed.map((c, i) => (
<span key={i} className="text-xs font-bold text-gray-300">{c}{i < transformed.length - 1 ? ' →' : ''}</span>
))}
</div>
</div>
<span className="text-[11px] text-gray-500">{style.desc}</span>
</div>
<span className="text-gray-600 shrink-0 ml-3">{isOpen ? '▲' : '▼'}</span>
</button>
{isOpen && (
<div className="border-t border-border/50 px-4 py-4">
<div className="flex flex-wrap gap-4">
{transformed.map((c, i) => {
const voicings = getBestVoicings(c, 2)
return (
<div key={i} className="flex flex-col items-center gap-2">
<button onClick={() => onChordClick?.(c)}
className="px-2 py-0.5 bg-panel border border-border hover:border-accent/50 rounded-lg font-bold text-sm text-gray-200 hover:text-accent transition-all">
{c}
</button>
<div className="flex gap-2">
{voicings.map((v, vi) => (
<div key={vi} className="flex flex-col items-center">
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
<p className="text-[9px] text-gray-700 text-center mt-0.5 max-w-[90px]">{v.label}</p>
</div>
))}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
})}
</div>
)
}
// ─── Similar famous progressions ─────────────────────────────────────────────
function SimilarSection({ progression, keyInfo, onChordClick }) {
const similar = findSimilarProgressions(progression, keyInfo)
if (!similar.length) return (
<p className="text-gray-600 text-sm text-center py-3">Play more and lock a key similar progressions will appear here.</p>
)
return (
<div className="flex flex-col gap-2">
{similar.slice(0, 3).map(prog => {
const chordsHere = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : []
return (
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl">
<div className="flex items-center flex-wrap gap-2 mb-2">
<span className="font-bold text-white text-sm">{prog.name}</span>
<span className="text-[10px] font-mono text-gray-600">{prog.pattern}</span>
<span className="text-xs text-gray-600 ml-auto">{Math.round(prog.score * 100)}% match</span>
</div>
{chordsHere.length > 0 && (
<div className="flex flex-wrap gap-1.5 items-center mb-2">
{chordsHere.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<button onClick={() => onChordClick?.(c)}
className={`px-2 py-0.5 rounded-lg font-bold text-xs border transition-all ${
i === 0 ? 'bg-accent border-accent text-white' : 'bg-panel border-border text-gray-300 hover:border-accent/50 hover:text-accent'
}`}>
{c}
</button>
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs"></span>}
</span>
))}
<span className="text-[10px] text-gray-600 ml-1">in {keyInfo?.root} {keyInfo?.mode}</span>
</div>
)}
<p className="text-[11px] text-gray-600">{prog.songs.slice(0, 3).join(' · ')}</p>
</div>
)
})}
</div>
)
}
// ─── Main panel ───────────────────────────────────────────────────────────────
export default function CurrentJamPanel({ keyInfo, chordHistory, detectedProgression, onChordClick }) {
const [open, setOpen] = useState(false)
const [view, setView] = useState('voicings') // voicings | scales | styles | similar
const { root, mode } = keyInfo ?? {}
// Working progression: detected loop or last 4 unique chords
const workingProgression = detectedProgression?.length
? detectedProgression
: [...new Set([...chordHistory].reverse())].reverse().slice(-4)
const scaleIdeas = SCALE_IDEAS[mode] ?? SCALE_FALLBACK
const rootPc = root ? NOTES.indexOf(root) : null
const hasSession = workingProgression.length > 0
return (
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all">
<div className="flex items-center gap-3">
<span>CURRENT JAM</span>
{root && (
<span className="text-[10px] px-2 py-0.5 bg-accent/10 border border-accent/30 rounded text-accent">
{root} {mode} {detectedProgression?.length ? `· ${workingProgression.join(' → ')}` : ''}
</span>
)}
</div>
<span>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div className="border-t border-border p-4 flex flex-col gap-4">
{!hasSession ? (
<p className="text-gray-600 text-sm text-center py-6">Start listening and play some chords your jam will appear here.</p>
) : (
<>
{/* ── Progression summary ── */}
<div className="flex flex-wrap items-center gap-2 px-3 py-2 bg-surface border border-border rounded-xl">
{root ? (
<span className="text-accent font-bold text-sm">{root} {mode}</span>
) : (
<span className="text-gray-600 text-sm">Key detecting</span>
)}
{workingProgression.length > 0 && (
<>
<span className="text-gray-700">·</span>
{workingProgression.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<span className="text-gray-300 font-bold text-sm">{c}</span>
{root && <span className="text-amber-400/60 text-[10px]">{toRomanNumeral(c, root, mode)}</span>}
{i < workingProgression.length - 1 && <span className="text-gray-700"></span>}
</span>
))}
</>
)}
</div>
{/* ── View tabs ── */}
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
{[
{ key: 'voicings', label: '🎸 Open Voicings' },
{ key: 'scales', label: '🎵 Scales to Solo' },
{ key: 'styles', label: '🎨 Style Options' },
{ key: 'similar', label: '🔗 Similar Progressions' },
].map(t => (
<button key={t.key} onClick={() => setView(t.key)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
view === t.key ? 'bg-accent text-white' : 'text-gray-400 hover:text-white'
}`}>
{t.label}
</button>
))}
</div>
{/* ── Voicings: per chord open shapes ── */}
{view === 'voicings' && (
<div className="flex flex-col gap-3">
<p className="text-xs text-gray-500">
Best open and barre voicings for each chord in your jam. Click a chord name to see all its voicings.
</p>
{workingProgression.map(chord => (
<ChordStrip key={chord} chordStr={chord} keyInfo={keyInfo} onChordClick={onChordClick} />
))}
</div>
)}
{/* ── Scales ── */}
{view === 'scales' && (
<div className="flex flex-col gap-3">
<p className="text-xs text-gray-500">
Scales and modes that fit {root ? `${root} ${mode}` : 'your current key'}.
Start with the pentatonic add the extra notes once you feel comfortable.
</p>
{scaleIdeas.map(idea => (
<div key={idea.name} className="p-3 bg-surface border border-border rounded-xl">
<div className="flex items-center gap-3 mb-2">
<span className="font-bold text-white text-sm">{idea.name}</span>
<span className="font-mono text-xs text-accent">{idea.intervals}</span>
</div>
{rootPc !== null && idea.scaleIntervals && (
<div className="mb-2 overflow-x-auto">
<RiffDiagram rootPc={rootPc} scaleIntervals={idea.scaleIntervals} />
<p className="text-[10px] text-gray-600 mt-1">
Purple = root · Grey = scale tone · Fret numbers above
</p>
</div>
)}
<p className="text-xs text-gray-400 leading-snug">{idea.desc}</p>
</div>
))}
<p className="text-[11px] text-gray-700 text-center">
Pro tip: always resolve to a chord tone at the end of a phrase 7 leading to root, or 3rd landing on the 1.
</p>
</div>
)}
{/* ── Style options ── */}
{view === 'styles' && (
<div className="flex flex-col gap-2">
<p className="text-xs text-gray-500">
Your progression re-voiced four ways. Expand any style to see the chord boxes.
</p>
<StyleSection progression={workingProgression} onChordClick={onChordClick} />
</div>
)}
{/* ── Similar progressions ── */}
{view === 'similar' && (
<SimilarSection progression={workingProgression} keyInfo={keyInfo} onChordClick={onChordClick} />
)}
</>
)}
</div>
)}
</div>
)
}
+397
View File
@@ -0,0 +1,397 @@
import { useState } from 'react'
import { toRomanNumeral, CHORD_TYPES, NOTES } from '../lib/theory'
import {
findSimilarProgressions,
getChordSubstitutions,
progressionInKey,
styleVariationInKey,
parseChord,
} from '../lib/education'
// ─── Session Snapshot ─────────────────────────────────────────────────────────
function SessionSnapshot({ keyInfo, detectedProgression, chordHistory }) {
const { root, mode, confidence } = keyInfo ?? {}
const uniqueChords = [...new Set(chordHistory)]
const totalPlayed = chordHistory.length
return (
<div className="flex flex-wrap gap-4 p-4 bg-surface border border-border rounded-xl">
<div className="flex flex-col gap-1 min-w-[120px]">
<p className="text-[11px] uppercase tracking-wider text-gray-600">Key</p>
{root ? (
<div className="flex items-baseline gap-1.5">
<span className="text-2xl font-black text-accent">{root}</span>
<span className="text-sm text-gray-400 capitalize">{mode}</span>
{confidence && <span className="text-xs text-gray-600">{Math.round(confidence * 100)}%</span>}
</div>
) : (
<span className="text-gray-600 text-sm">Detecting</span>
)}
</div>
<div className="w-px bg-border shrink-0" />
<div className="flex flex-col gap-1">
<p className="text-[11px] uppercase tracking-wider text-gray-600">Detected Loop</p>
{detectedProgression?.length ? (
<div className="flex flex-wrap gap-1">
{detectedProgression.map((chord, i) => (
<span key={i} className="px-2 py-0.5 bg-accent/10 border border-accent/30 rounded text-xs font-bold text-accent">
{chord}
{root && <span className="text-amber-400/70 ml-1 font-normal text-[10px]">
{toRomanNumeral(chord, root, mode)}
</span>}
</span>
))}
</div>
) : (
<span className="text-gray-600 text-sm">None yet keep playing!</span>
)}
</div>
<div className="w-px bg-border shrink-0" />
<div className="flex flex-col gap-1">
<p className="text-[11px] uppercase tracking-wider text-gray-600">Session</p>
<p className="text-sm text-gray-300">
<span className="font-bold text-white">{totalPlayed}</span> chords &nbsp;·&nbsp;
<span className="font-bold text-white">{uniqueChords.length}</span> unique
</p>
{uniqueChords.length > 0 && (
<div className="flex flex-wrap gap-1 mt-0.5">
{uniqueChords.slice(0, 10).map(c => (
<span key={c} className="text-[10px] text-gray-500 bg-border px-1.5 py-0.5 rounded">{c}</span>
))}
{uniqueChords.length > 10 && <span className="text-[10px] text-gray-600">+{uniqueChords.length - 10}</span>}
</div>
)}
</div>
</div>
)
}
// ─── Similar Progressions ─────────────────────────────────────────────────────
function SimilarProgressions({ similar, keyInfo, onChordClick }) {
const [expanded, setExpanded] = useState(null)
if (!similar.length) return (
<p className="text-gray-600 text-sm text-center py-4">
Play more chords and lock a key to find similar famous progressions.
</p>
)
return (
<div className="flex flex-col gap-3">
{similar.map(prog => {
const isOpen = expanded === prog.id
const chordsInKey = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : []
return (
<div key={prog.id}
className="border border-border rounded-xl overflow-hidden hover:border-accent/30 transition-colors">
{/* Header row */}
<button
onClick={() => setExpanded(isOpen ? null : prog.id)}
className="w-full flex items-start justify-between gap-3 px-4 py-3 text-left"
>
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-bold text-white text-sm">{prog.name}</span>
{prog.genre.map(g => (
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
))}
<span className="text-[11px] text-gray-500 font-mono">{prog.pattern}</span>
<span className="ml-auto text-xs text-gray-600">{Math.round(prog.score * 100)}% match</span>
</div>
{/* Chords in current key */}
{chordsInKey.length > 0 && (
<div className="flex gap-1 flex-wrap">
{chordsInKey.map((c, i) => (
<button key={i}
onClick={e => { e.stopPropagation(); onChordClick?.(c) }}
className="px-2 py-0.5 bg-surface border border-border hover:border-accent/50 rounded text-xs font-bold text-gray-200 hover:text-accent transition-colors"
title={`See voicings for ${c}`}
>
{c}
</button>
))}
<span className="text-[10px] text-gray-600 self-center ml-1">in {keyInfo.root} {keyInfo.mode}</span>
</div>
)}
</div>
<span className="text-gray-600 shrink-0 text-sm mt-0.5">{isOpen ? '▲' : '▼'}</span>
</button>
{/* Expanded detail */}
{isOpen && (
<div className="border-t border-border px-4 py-4 flex flex-col gap-4">
<p className="text-sm text-gray-400">{prog.description}</p>
{prog.tip && (
<p className="text-xs text-amber-400/80">
<span className="text-amber-400 font-semibold">Insight:</span> {prog.tip}
</p>
)}
{/* Song examples */}
<div>
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Famous examples</p>
<div className="flex flex-wrap gap-1.5">
{prog.songs.map(s => (
<span key={s} className="px-2 py-1 bg-surface border border-border rounded-lg text-xs text-gray-400">{s}</span>
))}
</div>
</div>
{/* Style variations */}
{prog.styleVariations.length > 0 && (
<div>
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Style variations</p>
<div className="flex flex-col gap-2">
{prog.styleVariations.map(sv => {
const svChords = keyInfo?.root ? styleVariationInKey(sv, prog, keyInfo.root) : []
return (
<div key={sv.label} className="flex items-start gap-3 p-2 bg-surface rounded-lg border border-border">
<span className="text-xs font-bold text-accent shrink-0 w-16">{sv.label}</span>
<div className="flex flex-col gap-1 min-w-0">
<span className="text-xs text-gray-500 font-mono">{sv.pattern}</span>
{svChords.length > 0 && (
<div className="flex gap-1 flex-wrap">
{svChords.map((c, i) => (
<button key={i}
onClick={() => onChordClick?.(c)}
className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 hover:border-accent rounded text-[11px] font-bold text-accent/90 hover:text-accent transition-colors"
title={`See voicings for ${c}`}
>
{c}
</button>
))}
<span className="text-[10px] text-gray-600 self-center ml-1">in {keyInfo.root}</span>
</div>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
)
}
// ─── Play It Differently ──────────────────────────────────────────────────────
function PlayDifferently({ progression, onChordClick }) {
if (!progression?.length) return (
<p className="text-gray-600 text-sm text-center py-4">
Keep playing a repeating progression will appear here with substitution ideas.
</p>
)
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-gray-500">
Tap any substitution to see how to play it. These are harmonic replacements same role, different colour.
</p>
{progression.map(chord => {
const subs = getChordSubstitutions(chord)
return (
<div key={chord} className="flex flex-wrap items-start gap-3 p-3 bg-surface border border-border rounded-xl">
{/* Original chord */}
<button
onClick={() => onChordClick?.(chord)}
className="px-3 py-1.5 bg-accent text-white font-black rounded-lg text-sm shrink-0 hover:bg-purple-600 transition-colors"
title="See voicings"
>
{chord}
</button>
<span className="text-gray-700 self-center"></span>
{/* Substitutions */}
<div className="flex flex-wrap gap-2">
{subs.map(sub => (
<div key={sub.chord} className="relative group">
<button
onClick={() => onChordClick?.(sub.chord)}
className="px-2.5 py-1.5 bg-panel border border-border hover:border-accent/50 hover:text-accent rounded-lg text-sm font-bold text-gray-300 transition-all"
>
{sub.chord}
</button>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1.5 w-48 px-2 py-1.5 bg-gray-900 border border-border rounded-lg text-[11px] text-gray-300 leading-snug opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10 shadow-xl">
{sub.tip}
</div>
</div>
))}
</div>
</div>
)
})}
<p className="text-[11px] text-gray-700 text-center">
Hover substitutions to see what they change · click to see voicings
</p>
</div>
)
}
// ─── Chord Variation Ideas ────────────────────────────────────────────────────
const VARIATION_ROWS = [
{
label: '7th Upgrade',
desc: 'Add 7ths throughout — jazz and soul texture',
typeMap: { maj: 'maj7', min: 'min7', dom7: 'dom7', maj7: 'maj7', min7: 'min7', dim: 'dim7', add9: 'maj7', sus2: 'sus2', sus4: 'sus4', aug: 'aug', half_dim: 'half_dim', maj6: 'maj6', min6: 'min6' },
},
{
label: 'Sus2 Wash',
desc: 'Replace triads with sus2 — ambient and spacious',
typeMap: { maj: 'sus2', min: 'sus2', dom7: 'sus4', maj7: 'sus2', min7: 'sus2', add9: 'sus2', dim: 'dim', aug: 'aug', sus4: 'sus4', sus2: 'sus2', half_dim: 'half_dim', maj6: 'sus2', min6: 'sus2' },
},
{
label: 'Add9 Modern',
desc: 'Add9 on majors, m7 on minors — indie and neo-soul',
typeMap: { maj: 'add9', min: 'min7', dom7: 'dom7', maj7: 'add9', min7: 'min7', add9: 'add9', sus2: 'sus2', sus4: 'sus4', dim: 'dim', aug: 'aug', half_dim: 'half_dim', maj6: 'add9', min6: 'min7' },
},
{
label: 'Blues Dominant',
desc: 'All chords → dom7 — instant 12-bar blues energy',
typeMap: { maj: 'dom7', min: 'dom7', dom7: 'dom7', maj7: 'dom7', min7: 'dom7', add9: 'dom7', sus2: 'dom7', sus4: 'dom7', dim: 'dim7', aug: 'aug', half_dim: 'dom7', maj6: 'dom7', min6: 'dom7' },
},
]
function ProgressionVariationIdeas({ progression, onChordClick }) {
if (!progression?.length) return (
<p className="text-gray-600 text-sm text-center py-4">
Keep playing your progression will appear here.
</p>
)
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-gray-500">
Your progression re-harmonised four ways. Click any chord to open its voicing explorer.
</p>
{VARIATION_ROWS.map(row => {
const transformed = progression.map(chord => {
const p = parseChord(chord)
if (!p) return chord
const newType = row.typeMap[p.type] ?? p.type
return NOTES[p.rootPc] + (CHORD_TYPES[newType]?.suffix ?? '')
})
return (
<div key={row.label} className="p-3 bg-surface border border-border rounded-xl">
<div className="flex items-center gap-2 mb-2.5">
<span className="text-xs font-bold text-accent">{row.label}</span>
<span className="text-[11px] text-gray-500">{row.desc}</span>
</div>
<div className="flex flex-wrap gap-2 items-center">
{progression.map((orig, i) => (
<span key={i} className="flex items-center gap-1.5">
<span className="text-[10px] text-gray-600">{orig}</span>
<span className="text-gray-700 text-xs"></span>
<button
onClick={() => onChordClick?.(transformed[i])}
className="px-2.5 py-1 bg-panel border border-border hover:border-accent/50 hover:text-accent rounded-lg font-bold text-sm text-gray-200 transition-all"
>
{transformed[i]}
</button>
{i < progression.length - 1 && <span className="text-gray-700">·</span>}
</span>
))}
</div>
</div>
)
})}
</div>
)
}
// ─── Main ─────────────────────────────────────────────────────────────────────
export default function EducationPanel({ chordHistory, keyInfo, detectedProgression, onChordClick }) {
const [open, setOpen] = useState(false)
const [section, setSection] = useState('similar')
// Use detected progression if available, else last 4 unique chords from history
const workingProgression = detectedProgression?.length
? detectedProgression
: [...new Set([...chordHistory].reverse())].reverse().slice(-4)
const similar = findSimilarProgressions(workingProgression, keyInfo)
return (
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button
onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all"
>
<div className="flex items-center gap-3">
<span>EDUCATION</span>
{similar.length > 0 && (
<span className="text-[10px] px-1.5 py-0.5 bg-accent/20 border border-accent/30 rounded text-accent">
{similar.length} match{similar.length !== 1 ? 'es' : ''}
</span>
)}
</div>
<span>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div className="border-t border-border">
{/* Section nav */}
<div className="flex gap-1 px-4 pt-4 pb-0 border-b border-border overflow-x-auto">
{[
{ key: 'snapshot', label: '📊 Session' },
{ key: 'similar', label: `🎵 Similar Progressions${similar.length ? ` (${similar.length})` : ''}` },
{ key: 'play', label: '🎨 Play Differently' },
{ key: 'variations',label: '🔀 Progression Variations' },
].map(s => (
<button key={s.key}
onClick={() => setSection(s.key)}
className={`px-3 py-2 text-xs font-semibold whitespace-nowrap border-b-2 transition-all shrink-0 ${
section === s.key
? 'border-accent text-accent'
: 'border-transparent text-gray-500 hover:text-gray-300'
}`}>
{s.label}
</button>
))}
</div>
<div className="p-4">
{section === 'snapshot' && (
<SessionSnapshot
keyInfo={keyInfo}
detectedProgression={detectedProgression}
chordHistory={chordHistory}
/>
)}
{section === 'similar' && (
<SimilarProgressions
similar={similar}
keyInfo={keyInfo}
onChordClick={onChordClick}
/>
)}
{section === 'play' && (
<PlayDifferently
progression={workingProgression}
onChordClick={onChordClick}
/>
)}
{section === 'variations' && (
<ProgressionVariationIdeas
progression={workingProgression}
onChordClick={onChordClick}
/>
)}
</div>
</div>
)}
</div>
)
}
+249
View File
@@ -0,0 +1,249 @@
import { useState } from 'react'
import ChordBox from './ChordBox'
import MiniPiano from './MiniPiano'
import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings'
import { CHORD_TYPES, NOTES, getChordsInKey, toRomanNumeral } from '../lib/theory'
import { FAMOUS_PROGRESSIONS, progressionInKey } from '../lib/education'
const CHORD_TYPE_OPTIONS = [
{ key: 'maj', label: 'Major' },
{ key: 'min', label: 'Minor' },
{ key: 'dom7', label: '7' },
{ key: 'maj7', label: 'maj7' },
{ key: 'min7', label: 'm7' },
{ key: 'dim', label: 'dim' },
{ key: 'dim7', label: 'dim7' },
{ key: 'half_dim', label: 'm7♭5' },
{ key: 'aug', label: 'aug' },
{ key: 'sus4', label: 'sus4' },
{ key: 'sus2', label: 'sus2' },
{ key: 'maj6', label: '6' },
{ key: 'min6', label: 'm6' },
{ key: 'add9', label: 'add9' },
]
const MAJOR_TYPES = new Set(['maj','maj7','maj6','add9','sus4','sus2','aug','dom7'])
// ─── Quick-pick chip row ──────────────────────────────────────────────────────
function ChipRow({ label, chords, active, keyInfo, onSelect }) {
if (!chords?.length) return null
return (
<div className="flex items-start gap-2 flex-wrap">
<span className="text-[10px] uppercase tracking-wider text-gray-600 w-16 pt-1 shrink-0">{label}</span>
<div className="flex flex-wrap gap-1.5">
{chords.map(chord => {
const rn = keyInfo?.root ? toRomanNumeral(chord, keyInfo.root, keyInfo.mode) : ''
return (
<button key={chord} onClick={() => onSelect(chord)}
className={`flex flex-col items-center px-2.5 py-1 rounded-lg border text-xs font-bold transition-all ${
active === chord
? 'bg-accent border-accent text-white'
: 'bg-surface border-border text-gray-300 hover:border-accent/50 hover:text-accent'
}`}>
<span>{chord}</span>
{rn && <span className="text-[9px] font-normal opacity-60 leading-none mt-0.5">{rn}</span>}
</button>
)
})}
</div>
</div>
)
}
// ─── Guitar voicings grid ─────────────────────────────────────────────────────
function GuitarGrid({ chordName }) {
const voicings = getGuitarVoicings(chordName)
if (!voicings.length) return <p className="text-gray-600 text-sm py-4">No voicings for {chordName}.</p>
return (
<div>
<div className="flex flex-wrap gap-4">
{voicings.map((v, i) => (
<div key={i} className="flex flex-col items-center p-3 rounded-xl bg-surface border border-border hover:border-accent/30 transition-colors">
<ChordBox frets={v.frets} fingers={v.fingers} barre={v.barre} baseFret={v.baseFret} />
<p className="text-[11px] text-gray-500 text-center mt-1 max-w-[110px] leading-tight">{v.label}</p>
</div>
))}
</div>
<p className="text-[11px] text-gray-700 mt-3">
Purple = chord tone · finger numbers inside dots (1=index 4=pinky) · fret number on left if not starting at fret 1
</p>
</div>
)
}
// ─── Piano techniques grid ────────────────────────────────────────────────────
function PianoGrid({ chordName }) {
const parsed = parseChord(chordName)
const techniques = getPianoTechniques(chordName)
const rootPc = parsed?.rootPc ?? 0
if (!techniques.length) return <p className="text-gray-600 text-sm py-4">No techniques for {chordName}.</p>
return (
<div className="flex flex-col gap-3">
{techniques.map((t, i) => (
<div key={i} className="flex flex-col lg:flex-row gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
<div className="shrink-0 overflow-x-auto">
<MiniPiano rootPc={rootPc} lh={t.lh} rh={t.rh} />
</div>
<div className="flex flex-col gap-1 min-w-0 justify-center">
<p className="font-bold text-white text-sm">{t.name}</p>
<p className="text-gray-400 text-xs">{t.desc}</p>
<p className="text-xs text-amber-400/80 mt-0.5">
<span className="text-amber-400 font-semibold">Tip:</span> {t.tip}
</p>
</div>
</div>
))}
</div>
)
}
// ─── Famous progressions using this chord as tonic ───────────────────────────
function ProgressionCards({ chordName, onChordClick }) {
const parsed = parseChord(chordName)
if (!parsed) return null
const { rootPc, type } = parsed
const root = NOTES[rootPc]
const isMajor = MAJOR_TYPES.has(type)
const matching = FAMOUS_PROGRESSIONS.filter(p => {
const q0 = p.qualities[0]
return isMajor ? MAJOR_TYPES.has(q0) : !MAJOR_TYPES.has(q0)
}).slice(0, 6)
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-gray-500">
Famous progressions with <span className="text-accent font-bold">{chordName}</span> as the tonic.
Click any chord to see its voicings.
</p>
{matching.map(prog => {
const chordsHere = progressionInKey(prog, root)
return (
<div key={prog.id} className="p-3 bg-surface border border-border rounded-xl">
<div className="flex items-center flex-wrap gap-2 mb-2">
<span className="font-bold text-white text-sm">{prog.name}</span>
<span className="text-[10px] font-mono text-gray-600">{prog.pattern}</span>
{prog.genre.slice(0, 2).map(g => (
<span key={g} className="px-1.5 py-0.5 bg-accent/10 border border-accent/20 rounded text-[10px] text-accent">{g}</span>
))}
</div>
<div className="flex flex-wrap gap-1.5 items-center mb-2">
{chordsHere.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<button onClick={() => onChordClick?.(c)}
className={`px-2.5 py-1 rounded-lg font-bold text-sm border transition-all ${
i === 0
? 'bg-accent border-accent text-white'
: 'bg-panel border-border text-gray-200 hover:border-accent/50 hover:text-accent'
}`}>
{c}
</button>
{i < chordsHere.length - 1 && <span className="text-gray-700 text-xs"></span>}
</span>
))}
</div>
<p className="text-xs text-gray-600 leading-snug">{prog.description}</p>
{prog.songs.length > 0 && (
<p className="text-[11px] text-gray-700 mt-1">{prog.songs.slice(0, 3).join(' · ')}</p>
)}
</div>
)
})}
</div>
)
}
// ─── Main panel ───────────────────────────────────────────────────────────────
export default function ExplorePanel({ keyInfo, chordHistory, onChordClick }) {
const [open, setOpen] = useState(false)
const [root, setRoot] = useState('C')
const [typeKey, setTypeKey] = useState('maj')
const [view, setView] = useState('guitar') // guitar | piano | progressions
const [active, setActive] = useState('')
const chordName = root + (CHORD_TYPES[typeKey]?.suffix ?? '')
function selectChord(chord) {
setActive(chord)
const p = parseChord(chord)
if (p) { setRoot(NOTES[p.rootPc]); setTypeKey(p.type) }
}
// Context-aware quick-picks
const recentChords = [...new Set([...(chordHistory ?? [])].reverse())].slice(0, 12)
const keyChords = keyInfo?.root ? getChordsInKey(keyInfo.root, keyInfo.mode ?? 'major') : []
return (
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button onClick={() => setOpen(v => !v)}
className="w-full flex items-center justify-between px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-all">
<span>EXPLORE ANY CHORD</span>
<span>{open ? '▲' : '▼'}</span>
</button>
{open && (
<div className="border-t border-border p-4 flex flex-col gap-4">
{/* ── Context quick-picks ── */}
{(recentChords.length > 0 || keyChords.length > 0) && (
<div className="flex flex-col gap-2.5 p-3 bg-surface border border-border rounded-xl">
<ChipRow label="History" chords={recentChords} active={active} keyInfo={keyInfo} onSelect={selectChord} />
{keyChords.length > 0 && recentChords.length > 0 && <div className="h-px bg-border" />}
{keyChords.length > 0 && (
<ChipRow
label={keyInfo.root + ' ' + (keyInfo.mode ?? '')}
chords={keyChords} active={active} keyInfo={keyInfo} onSelect={selectChord}
/>
)}
</div>
)}
{/* ── Manual chord picker ── */}
<div className="flex flex-wrap gap-2 items-center p-3 bg-surface border border-border rounded-xl">
<div className="flex flex-wrap gap-1">
{NOTES.map(n => (
<button key={n} onClick={() => { setRoot(n); setActive('') }}
className={`px-2 py-0.5 rounded text-xs font-bold transition-all ${
root === n ? 'bg-accent text-white' : 'bg-border text-gray-400 hover:text-white'
}`}>
{n}
</button>
))}
</div>
<div className="w-px h-5 bg-border shrink-0" />
<div className="relative">
<select value={typeKey} onChange={e => { setTypeKey(e.target.value); setActive('') }}
className="appearance-none bg-panel border border-border rounded-lg pl-2 pr-6 py-1 text-xs text-gray-200 cursor-pointer focus:outline-none focus:border-accent">
{CHORD_TYPE_OPTIONS.map(o => <option key={o.key} value={o.key}>{o.label}</option>)}
</select>
<span className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-gray-500 text-xs"></span>
</div>
<div className="text-2xl font-black text-accent ml-2">{chordName}</div>
</div>
{/* ── View tabs ── */}
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 w-fit">
{[
{ key: 'guitar', label: '🎸 Guitar Voicings' },
{ key: 'piano', label: '🎹 Piano Techniques' },
{ key: 'progressions', label: '🎵 Progressions' },
].map(t => (
<button key={t.key} onClick={() => setView(t.key)}
className={`px-3 py-1.5 rounded-lg text-xs font-semibold transition-all whitespace-nowrap ${
view === t.key ? 'bg-accent text-white' : 'text-gray-400 hover:text-white'
}`}>
{t.label}
</button>
))}
</div>
{/* ── Content ── */}
{view === 'guitar' && <GuitarGrid chordName={chordName} />}
{view === 'piano' && <PianoGrid chordName={chordName} />}
{view === 'progressions' && <ProgressionCards chordName={chordName} onChordClick={c => { selectChord(c); onChordClick?.(c) }} />}
</div>
)}
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
// 2-octave mini piano keyboard showing technique notes
// Props:
// rootPc — root pitch class 0-11
// lh — array of semitone intervals above root (left hand, shown in blue)
// rh — array of semitone intervals above root (right hand, shown in purple)
const OCTAVES = 2
const WW = 22 // white key width
const WH = 60 // white key height
const BW = 14 // black key width
const BH = 38 // black key height
// White key pitch classes within an octave, in order
const WHITE_PCS = [0, 2, 4, 5, 7, 9, 11] // C D E F G A B
const WHITE_NAMES = ['C','D','E','F','G','A','B']
// Black key offsets (x position relative to white key 0) and pitch classes
const BLACK_OFFSETS = [
{ pc: 1, afterWhite: 0 }, // C#
{ pc: 3, afterWhite: 1 }, // D#
{ pc: 6, afterWhite: 3 }, // F#
{ pc: 8, afterWhite: 4 }, // G#
{ pc: 10, afterWhite: 5 }, // A#
]
const TOTAL_WHITES = WHITE_PCS.length * OCTAVES // 14
const SVG_W = WW * TOTAL_WHITES + 2
const SVG_H = WH + 24
function noteColor(interval) {
// interval < 12 → first octave (root region), ≥12 → second octave
return interval < 12 ? '#a855f7' : '#c084fc'
}
function handLabel(hand) {
return hand === 'L' ? 'LH' : 'RH'
}
export default function MiniPiano({ rootPc, lh = [], rh = [] }) {
// Build a set of highlighted notes: pc → { hand, interval }
// We span 2 octaves (semitones 0…23 above root), mapped to absolute pitch classes
const highlights = new Map() // absIdx → { color, label }
function addNotes(intervals, hand) {
for (const iv of intervals) {
const octave = Math.floor(iv / 12)
const pc = (rootPc + iv) % 12
const absIdx = octave * 12 + pc // unique index per octave slot
highlights.set(`${octave}-${pc}`, { color: hand === 'L' ? '#3b82f6' : '#a855f7', label: handLabel(hand) })
}
}
addNotes(lh, 'L')
addNotes(rh, 'R')
function isHighlighted(octave, pc) {
return highlights.get(`${octave}-${pc}`)
}
// White keys
const whites = []
for (let oct = 0; oct < OCTAVES; oct++) {
for (let wi = 0; wi < WHITE_PCS.length; wi++) {
const pc = WHITE_PCS[wi]
const absWi = oct * WHITE_PCS.length + wi
const x = absWi * WW + 1
const hl = isHighlighted(oct, pc)
whites.push({ x, pc, oct, wi, absWi, hl, name: WHITE_NAMES[wi] + (oct + 4) })
}
}
// Black keys
const blacks = []
for (let oct = 0; oct < OCTAVES; oct++) {
for (const { pc, afterWhite } of BLACK_OFFSETS) {
const absWi = oct * WHITE_PCS.length + afterWhite
const x = absWi * WW + WW - BW / 2
const hl = isHighlighted(oct, pc)
blacks.push({ x, pc, oct, hl })
}
}
return (
<svg width={SVG_W} height={SVG_H} viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="overflow-visible">
{/* White keys */}
{whites.map(({ x, hl, name, absWi }) => (
<g key={`w${absWi}`}>
<rect
x={x} y={1} width={WW - 1} height={WH}
rx={2}
fill={hl ? hl.color : '#f5f5f5'}
stroke="#374151"
strokeWidth={0.5}
/>
{hl && (
<text x={x + (WW - 1) / 2} y={WH - 8}
textAnchor="middle" fill="white" fontSize={7} fontWeight="bold">
{hl.label}
</text>
)}
</g>
))}
{/* Black keys */}
{blacks.map(({ x, pc, oct, hl }, i) => (
<g key={`b${oct}-${pc}`}>
<rect
x={x} y={1} width={BW} height={BH}
rx={2}
fill={hl ? hl.color : '#1f2937'}
stroke="#111827"
strokeWidth={0.5}
/>
{hl && (
<text x={x + BW / 2} y={BH - 5}
textAnchor="middle" fill="white" fontSize={6} fontWeight="bold">
{hl.label}
</text>
)}
</g>
))}
{/* Root label at bottom */}
{whites.map(({ x, pc, oct, name, absWi }) => {
const isRoot = pc === rootPc && oct === 0
if (!isRoot) return null
return (
<text key={`lbl${absWi}`} x={x + (WW - 1) / 2} y={WH + 14}
textAnchor="middle" fill="#a855f7" fontSize={8} fontWeight="bold">
R
</text>
)
})}
</svg>
)
}
+17 -10
View File
@@ -17,7 +17,7 @@ function findLoopPosition(chordHistory, progression) {
return progression.indexOf(last)
}
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord }) {
export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgression, currentChord, onChordClick }) {
const { root, mode, confidence } = keyInfo ?? {}
const visible = chordHistory.slice(-HISTORY_SHOWN)
@@ -75,10 +75,11 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
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 ${
onClick={() => onChordClick?.(chord)}
className={`flex flex-col items-center shrink-0 px-2 py-1 rounded-xl transition-colors duration-200 cursor-pointer ${
isCurrent
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20'
: 'border border-transparent'
? 'bg-accent/10 border border-accent/40 ring-1 ring-accent/20 hover:bg-accent/20'
: 'border border-transparent hover:border-border hover:bg-panel'
}`}
>
<span className={`font-black leading-none tracking-tight ${
@@ -108,10 +109,11 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
return (
<div
key={i}
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 ${
onClick={() => onChordClick?.(chord)}
className={`flex flex-col items-center px-2 py-0.5 rounded-lg border transition-all duration-200 cursor-pointer ${
isActive
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)]'
: 'bg-border border-border'
? 'bg-accent/20 border-accent shadow-[0_0_10px_rgba(168,85,247,0.3)] hover:bg-accent/30'
: 'bg-border border-border hover:border-gray-500'
}`}
>
<span className={`text-sm font-bold leading-none ${isActive ? 'text-accent' : 'text-gray-300'}`}>
@@ -132,11 +134,16 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr
{/* ── Right: big chord ── */}
<div className="hidden lg:flex w-[30%] flex-col items-center justify-center gap-1">
{current ? (
<>
<button
onClick={() => onChordClick?.(current)}
className="flex flex-col items-center gap-1 px-4 py-2 rounded-xl hover:bg-accent/10 transition-colors group"
title="Click to see voicings"
>
<p className="text-xs text-gray-600 uppercase tracking-widest">Now Playing</p>
<div className="text-6xl font-black text-amber-400 leading-none">{current}</div>
<div className="text-6xl font-black text-amber-400 leading-none group-hover:text-accent transition-colors">{current}</div>
<div className="text-sm text-gray-500">{currentRN}</div>
</>
<p className="text-[10px] text-gray-700 group-hover:text-gray-500 transition-colors">tap for voicings</p>
</button>
) : (
<p className="text-gray-600 text-xs text-center">Play a chord</p>
)}
+98
View File
@@ -0,0 +1,98 @@
// ─── Mini fretboard scale diagram ─────────────────────────────────────────────
// Shows a 6-string × 5-fret window of scale tones.
// Root notes → purple fill. Scale tones → dark grey fill.
// Strings: top = s6 (low E), bottom = s1 (high e).
// Window starts at the root fret on string 6.
const OPEN_PITCHES = [4, 9, 2, 7, 11, 4] // E A D G B e (s6 … s1)
const FRETS = 5
export default function RiffDiagram({ rootPc, scaleIntervals = [0, 3, 5, 7, 10] }) {
if (rootPc === undefined || rootPc === null) return null
// Fret window starts where the root lands on s6 (low E)
const startFret = (rootPc - OPEN_PITCHES[0] + 12) % 12
// Which pitch classes are in the scale?
const scaleSet = new Set(scaleIntervals.map(i => (rootPc + i) % 12))
// Collect dots: { s (0=s6…5=s1), f (0-4 within window), isRoot }
const dots = []
for (let s = 0; s < 6; s++) {
for (let f = 0; f < FRETS; f++) {
const pc = (OPEN_PITCHES[s] + startFret + f) % 12
if (scaleSet.has(pc)) {
dots.push({ s, f, isRoot: pc === rootPc })
}
}
}
// SVG layout
const W = 152, H = 70
const mL = 6, mT = 14, mR = 6, mB = 4
const innerW = W - mL - mR // 140
const innerH = H - mT - mB // 52
const cellW = innerW / FRETS // 28
const strGap = innerH / 5 // gap between 6 strings (5 gaps)
const sx = (f) => mL + f * cellW // left edge of fret cell
const cx = (f) => mL + (f + 0.5) * cellW // centre of fret cell
const sy = (s) => mT + s * strGap // y of string s
return (
<svg width={W} height={H} className="shrink-0 overflow-visible">
{/* Fret separators (vertical lines) */}
{Array.from({ length: FRETS + 1 }, (_, f) => (
<line key={f}
x1={sx(f)} y1={mT - 2}
x2={sx(f)} y2={H - mB}
stroke={f === 0 ? '#555' : '#2a2a2a'}
strokeWidth={f === 0 ? 2 : 1}
/>
))}
{/* String lines (horizontal) */}
{Array.from({ length: 6 }, (_, s) => (
<line key={s}
x1={mL} y1={sy(s)}
x2={W - mR} y2={sy(s)}
stroke="#3a3a3a"
strokeWidth={s === 0 ? 1.5 : 1}
/>
))}
{/* Fret numbers above */}
{Array.from({ length: FRETS }, (_, f) => (
<text key={f}
x={cx(f)} y={9}
textAnchor="middle" fontSize={8}
fill={f === 0 && startFret > 0 ? '#a855f7' : '#555'}
fontWeight={f === 0 && startFret > 0 ? 'bold' : 'normal'}>
{startFret + f === 0 ? 'O' : startFret + f}
</text>
))}
{/* Scale dots */}
{dots.map((d, i) => (
<circle key={i}
cx={cx(d.f)} cy={sy(d.s)}
r={4.5}
fill={d.isRoot ? '#a855f7' : '#3d3d3d'}
stroke={d.isRoot ? '#c084fc' : '#606060'}
strokeWidth={1}
/>
))}
{/* Root labels */}
{dots.filter(d => d.isRoot).map((d, i) => (
<text key={i}
x={cx(d.f)} y={sy(d.s) + 3.5}
textAnchor="middle" fontSize={6}
fill="white" fontWeight="bold">
R
</text>
))}
</svg>
)
}
+436
View File
@@ -0,0 +1,436 @@
import { NOTES, CHORD_TYPES } from './theory'
// ─── Parse helper (self-contained, mirrors voicings.js) ───────────────────────
export function parseChord(name) {
if (!name) return null
const FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']
let rest = name
let root = rest.length > 1 && (rest[1] === '#' || rest[1] === 'b')
? (rest = rest.slice(2), name.slice(0, 2))
: (rest = rest.slice(1), name.slice(0, 1))
let rootPc = NOTES.indexOf(root)
if (rootPc === -1) rootPc = FLAT.indexOf(root)
if (rootPc === -1) return null
const suffixMap = {
'': 'maj', 'm': 'min', 'min': 'min', 'maj': 'maj',
'7': 'dom7', 'maj7': 'maj7', 'm7': 'min7', 'min7': 'min7',
'dim': 'dim', 'dim7': 'dim7', 'm7b5': 'half_dim', 'ø': 'half_dim',
'aug': 'aug', '+': 'aug',
'sus4': 'sus4', 'sus2': 'sus2',
'6': 'maj6', 'm6': 'min6', 'add9': 'add9',
}
return { rootPc, type: suffixMap[rest] ?? 'maj' }
}
function chordName(rootPc, type) {
return NOTES[rootPc] + (CHORD_TYPES[type]?.suffix ?? '')
}
// ─── Famous progressions ──────────────────────────────────────────────────────
// degrees[] — semitone offsets from tonic
// qualities[] — chord type keys (CHORD_TYPES) for each degree
// mode — the mode this progression is naturally written in
// styleVariations — how genre players re-harmonise these chords
export const FAMOUS_PROGRESSIONS = [
{
id: 'axis',
name: 'Axis Progression',
pattern: 'I V vi IV',
degrees: [0, 7, 9, 5],
qualities: ['maj', 'maj', 'min', 'maj'],
mode: 'major',
genre: ['Pop', 'Rock'],
songs: ['Let It Be — Beatles', 'With or Without You — U2', 'Someone Like You — Adele', "Don't Stop Believin' — Journey", 'Wonderwall — Oasis', 'Demons — Imagine Dragons'],
description: 'The defining progression of modern pop. Works in every genre and tempo.',
tip: 'Try starting on the vi instead — suddenly it feels darker and more yearning.',
styleVariations: [
{ label: 'Jazz', pattern: 'Imaj7 V7 vim7 IVmaj7', qualities: ['maj7','dom7','min7','maj7'] },
{ label: 'Soul', pattern: 'Imaj9 V9 vim9 IVmaj9', qualities: ['maj7','dom7','min7','maj7'] },
{ label: 'Blues', pattern: 'I7 V7 vi7 IV7', qualities: ['dom7','dom7','dom7','dom7'] },
{ label: 'Ambient', pattern: 'Isus2 Vsus2 vim7 IVadd9', qualities: ['sus2','sus2','min7','add9'] },
],
},
{
id: 'fifties',
name: '50s / Doo-Wop',
pattern: 'I vi IV V',
degrees: [0, 9, 5, 7],
qualities: ['maj', 'min', 'maj', 'maj'],
mode: 'major',
genre: ['Pop', 'Doo-Wop', 'Rock'],
songs: ['Stand By Me — Ben E. King', 'Earth Angel', 'Blue Moon', 'Unchained Melody', 'Every Breath You Take — Police'],
description: 'The doo-wop backbone. Sweet, nostalgic, universally singable and timeless.',
tip: "The vi chord is the emotional pivot — it's the same root as in the Axis, just a different order.",
styleVariations: [
{ label: 'Jazz', pattern: 'Imaj7 vim7 IVmaj7 V7', qualities: ['maj7','min7','maj7','dom7'] },
{ label: 'Soul', pattern: 'I6 vim7 IV V7sus4', qualities: ['maj6','min7','maj','sus4'] },
{ label: 'Funk', pattern: 'Imaj9 vim9 IV9 V9', qualities: ['maj7','min7','dom7','dom7'] },
],
},
{
id: 'canon',
name: 'Canon / Pachelbel',
pattern: 'I V vi iii IV I IV V',
degrees: [0, 7, 9, 4, 5, 0, 5, 7],
qualities: ['maj','maj','min','min','maj','maj','maj','maj'],
mode: 'major',
genre: ['Classical', 'Pop', 'Rock'],
songs: ['Canon in D — Pachelbel', 'Basket Case — Green Day', 'Go West — Pet Shop Boys', 'Graduation — Vitamin C'],
description: 'Baroque timelessness. The descending bass line creates inevitable forward motion.',
tip: 'The iii chord is the secret ingredient — it bridges vi and IV with aristocratic weight.',
styleVariations: [
{ label: 'Rock', pattern: 'Power chords all the way down', qualities: [] },
{ label: 'Neo-soul',pattern: 'Imaj9 V9 vim9 iiim7 IVmaj9', qualities: ['maj7','dom7','min7','min7','maj7'] },
],
},
{
id: 'two_five_one',
name: 'ii V I (Jazz)',
pattern: 'iim7 V7 Imaj7',
degrees: [2, 7, 0],
qualities: ['min7', 'dom7', 'maj7'],
mode: 'major',
genre: ['Jazz', 'Bossa Nova'],
songs: ['Autumn Leaves', 'All The Things You Are', 'Fly Me To The Moon', 'The Girl From Ipanema', 'Misty'],
description: 'The bedrock of jazz harmony. The tritone in V7 resolves to I with beautiful tension.',
tip: 'The ii chord pre-resolves the V — together they create an inevitable pull to the I.',
styleVariations: [
{ label: 'Bossa', pattern: 'iim7 V7(9) Imaj9', qualities: ['min7','dom7','maj7'] },
{ label: 'Bebop', pattern: 'iim7 V7(♭9) Imaj7(#11)', qualities: ['min7','dom7','maj7'] },
{ label: 'Modal', pattern: 'im7 IV7 (Dorian vamp)', qualities: ['min7','dom7'] },
],
},
{
id: 'blues_12',
name: '12-Bar Blues',
pattern: 'I I I I IV IV I I V IV I V',
degrees: [0, 0, 0, 0, 5, 5, 0, 0, 7, 5, 0, 7],
qualities: ['dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7','dom7'],
mode: 'major',
genre: ['Blues', 'Rock', 'Jazz'],
songs: ['Johnny B. Goode — Chuck Berry', 'Pride & Joy — SRV', 'Crossroads — Robert Johnson', 'Hound Dog', 'Folsom Prison Blues — Cash'],
description: 'The foundation of rock and blues. Master this and you can jam with anyone on Earth.',
tip: 'All three chords are dominant 7ths — that dissonance is what makes blues feel so restless.',
styleVariations: [
{ label: 'Shuffle', pattern: 'I7 IV7 V7 with shuffle rhythm', qualities: ['dom7','dom7','dom7'] },
{ label: 'Jazz', pattern: 'Imaj7 IV7 iim7 V7 quick changes', qualities: ['maj7','dom7','min7','dom7'] },
{ label: 'Minor', pattern: 'im7 ivm7 vm7 (minor blues)', qualities: ['min7','min7','min7'] },
],
},
{
id: 'andalusian',
name: 'Andalusian Cadence',
pattern: 'i ♭VII ♭VI V',
degrees: [0, 10, 8, 7],
qualities: ['min', 'maj', 'maj', 'maj'],
mode: 'minor',
genre: ['Flamenco', 'Rock', 'Classical'],
songs: ['Stairway to Heaven intro — Led Zeppelin', 'Hit the Road Jack', 'Sultans of Swing — Dire Straits', 'White Christmas'],
description: 'Descending bass line creates unstoppable forward motion. Timeless, dramatic, inevitable.',
tip: 'The final V chord (major) is the harmonic surprise in a minor context — forces resolution.',
styleVariations: [
{ label: 'Flamenco', pattern: 'im ♭VII ♭VI V7', qualities: ['min','maj','maj','dom7'] },
{ label: 'Rock', pattern: 'im5 ♭VII5 ♭VI5 V5 (power)', qualities: ['min','maj','maj','maj'] },
{ label: 'Jazz', pattern: 'im(maj7) ♭VIImaj7 ♭VImaj7 V7(#9)', qualities: ['min7','maj7','maj7','dom7'] },
],
},
{
id: 'minor_anthem',
name: 'Minor Anthem',
pattern: 'i ♭VI ♭III ♭VII',
degrees: [0, 8, 3, 10],
qualities: ['min', 'maj', 'maj', 'maj'],
mode: 'minor',
genre: ['Rock', 'Pop', 'Metal'],
songs: ['Numb — Linkin Park', 'In The End — Linkin Park', 'Boulevard of Broken Dreams — Green Day', 'Creep — Radiohead', 'Smells Like Teen Spirit — Nirvana'],
description: 'The anthem of angst. Powerful, relentless, emotionally direct.',
tip: 'Every chord is major except the tonic — the contrast makes the i feel even more desperate.',
styleVariations: [
{ label: 'Stripped', pattern: 'im7 ♭VImaj7 ♭IIImaj7 ♭VIImaj7', qualities: ['min7','maj7','maj7','maj7'] },
{ label: 'Epic', pattern: 'im ♭VI ♭III ♭VII with sus2 variants', qualities: ['min','maj','maj','maj'] },
],
},
{
id: 'minor_oscillate',
name: 'Minor Oscillation',
pattern: 'i ♭VII ♭VI ♭VII',
degrees: [0, 10, 8, 10],
qualities: ['min', 'maj', 'maj', 'maj'],
mode: 'minor',
genre: ['Rock', 'Folk', 'Pop'],
songs: ['All Along the Watchtower — Dylan/Hendrix', "Knockin' on Heaven's Door — Dylan", 'Pumped Up Kicks — Foster the People', 'Africa — Toto'],
description: 'The ♭VII oscillates back and forth — creates hypnotic looping energy.',
tip: 'Works as a 2-bar vamp (i ♭VII) or a full 4-bar loop. The ♭VI adds breathing room.',
styleVariations: [
{ label: 'Folk', pattern: 'im ♭VII ♭VI ♭VII fingerpicked', qualities: ['min','maj','maj','maj'] },
{ label: 'Rock', pattern: 'im ♭VII ♭VI power chords', qualities: ['min','maj','maj','maj'] },
],
},
{
id: 'mixolydian',
name: 'Mixolydian Rock',
pattern: 'I ♭VII IV',
degrees: [0, 10, 5],
qualities: ['maj', 'maj', 'maj'],
mode: 'major',
genre: ['Rock', 'Folk', 'Celtic'],
songs: ['Sweet Home Alabama — Lynyrd Skynyrd', 'La Grange — ZZ Top', 'Werewolves of London — Warren Zevon', 'Norwegian Wood — Beatles'],
description: 'The ♭VII chord defines Mixolydian mode. Swagger and swagger only.',
tip: 'In standard major, the VII is diminished. Flattening it to ♭VII gives you a major chord — that shift is everything.',
styleVariations: [
{ label: 'Celtic', pattern: 'I ♭VII IV I alternating', qualities: ['maj','maj','maj','maj'] },
{ label: 'Funk', pattern: 'I9 ♭VII9 IV9 dominant 9ths', qualities: ['dom7','dom7','dom7'] },
],
},
{
id: 'dorian_vamp',
name: 'Dorian Vamp',
pattern: 'i IV',
degrees: [0, 5],
qualities: ['min', 'maj'],
mode: 'minor',
genre: ['Rock', 'Jazz', 'Funk'],
songs: ['Oye Como Va — Santana', 'So What — Miles Davis', 'Scarborough Fair', 'Eleanor Rigby verse — Beatles', 'Mad World — Tears for Fears'],
description: 'The major IV over a minor i chord signals Dorian mode. Smooth, open, sophisticated.',
tip: 'Natural minor would have a minor iv. The major IV is Dorian\'s signature — it feels both sad and groovy.',
styleVariations: [
{ label: 'Jazz', pattern: 'im7 IV7 (comp beneath a soloist)', qualities: ['min7','dom7'] },
{ label: 'Funk', pattern: 'im9 IV13 (layered synth and guitar)', qualities: ['min7','dom7'] },
{ label: 'Rock', pattern: 'im IV im IV (guitar riff)', qualities: ['min','maj'] },
],
},
{
id: 'jazz_turnaround',
name: 'Jazz Turnaround',
pattern: 'I vi ii V',
degrees: [0, 9, 2, 7],
qualities: ['maj7', 'min7', 'min7', 'dom7'],
mode: 'major',
genre: ['Jazz', 'Swing'],
songs: ['I Got Rhythm — Gershwin', 'Rhythm Changes', 'How High the Moon', 'countless jazz standards'],
description: 'The jazz turnaround — loops back to the top of the form with elegant inevitability.',
tip: 'Each chord resolves down a fifth to the next. That chain of fifths is what makes jazz sound "right".',
styleVariations: [
{ label: 'Bebop', pattern: 'Imaj7 vim7 iim7 V7(♭9)', qualities: ['maj7','min7','min7','dom7'] },
{ label: 'Tritone', pattern: 'Imaj7 ♭III7 iim7 ♭II7 (tritone subs)', qualities: ['maj7','dom7','min7','dom7'] },
],
},
{
id: 'phrygian',
name: 'Phrygian Tension',
pattern: 'i ♭II',
degrees: [0, 1],
qualities: ['min', 'maj'],
mode: 'minor',
genre: ['Flamenco', 'Metal', 'Film'],
songs: ['Game of Thrones theme', 'Spanish flamenco standards', 'heavy metal riffs', 'El Tango de Roxanne'],
description: 'The ♭II (Neapolitan) chord creates extreme tension. Spanish fire and metal darkness.',
tip: 'Just two chords — the half-step relationship between roots is what makes it feel so tense.',
styleVariations: [
{ label: 'Flamenco', pattern: 'im ♭II im with fast strumming', qualities: ['min','maj','min'] },
{ label: 'Metal', pattern: 'im5 ♭II5 power chord riff', qualities: ['min','maj'] },
],
},
{
id: 'minor_pop',
name: 'Sad Pop Minor',
pattern: 'vi IV I V',
degrees: [9, 5, 0, 7],
qualities: ['min', 'maj', 'maj', 'maj'],
mode: 'major',
genre: ['Pop', 'Rock', 'Indie'],
songs: ['Zombie — Cranberries', 'Torn — Natalie Imbruglia', 'Apologize — Timbaland', 'Fix You — Coldplay'],
description: 'Same chords as the Axis — just starting on the vi. Instantly feels darker and more yearning.',
tip: 'Which chord you start on changes everything emotionally. This is the Axis heard through minor eyes.',
styleVariations: [
{ label: 'Soul', pattern: 'vim7 IVmaj7 Imaj7 V9', qualities: ['min7','maj7','maj7','dom7'] },
{ label: 'Indie', pattern: 'vim7 IVadd9 Iadd9 Vsus4', qualities: ['min7','add9','add9','sus4'] },
],
},
{
id: 'lydian_float',
name: 'Lydian Float',
pattern: 'I II',
degrees: [0, 2],
qualities: ['maj', 'maj'],
mode: 'major',
genre: ['Film', 'Jazz', 'Pop'],
songs: ['The Simpsons theme', 'Joe Satriani — Flying in a Blue Dream', 'many John Williams cues', 'Man or Muppet'],
description: 'The raised ♯4 in Lydian makes the II chord major instead of minor. Dreamy, floating, magical.',
tip: 'In normal major, the II chord is minor. Making it major (Lydian) lifts the whole progression off the ground.',
styleVariations: [
{ label: 'Film', pattern: 'Imaj7 IImaj7 slowly held', qualities: ['maj7','maj7'] },
{ label: 'Jazz', pattern: 'Imaj7(#11) — the Lydian 7th chord', qualities: ['maj7','maj7'] },
],
},
{
id: 'sensitive',
name: 'Sensitive Oscillation',
pattern: 'vi V IV V',
degrees: [9, 7, 5, 7],
qualities: ['min', 'maj', 'maj', 'maj'],
mode: 'major',
genre: ['Pop', 'Indie'],
songs: ['Mad World — Tears for Fears', 'Every Breath You Take — Police', 'Losing My Religion — REM'],
description: 'Oscillates between vi and IV through V. Aching, introspective, perpetually unresolved.',
tip: 'The V never quite resolves to I — it keeps bouncing back to the IV or vi. That suspension is the emotion.',
styleVariations: [
{ label: 'Indie', pattern: 'vim7 Vsus4 IVadd9 V', qualities: ['min7','sus4','add9','maj'] },
],
},
]
// ─── Chord substitution options ───────────────────────────────────────────────
// For each chord type: array of { type, label, tip } suggestions
export const CHORD_SUBSTITUTIONS = {
maj: [
{ type: 'maj7', tip: 'Add the major 7th — dreamy jazz colour' },
{ type: 'add9', tip: 'Add the 9th — modern, open, Coldplay-ish' },
{ type: 'maj6', tip: 'Add major 6th — vintage Django jazz sweetness' },
{ type: 'sus2', tip: 'Replace 3rd with 2nd — airy and ambiguous' },
{ type: 'sus4', tip: 'Suspend then resolve — creates rhythmic motion' },
],
min: [
{ type: 'min7', tip: 'Add the minor 7th — smooth soul and jazz' },
{ type: 'add9', tip: 'Add 9th over minor — bittersweet modern ache (Radiohead)' },
{ type: 'min6', tip: 'Add major 6th over minor — flamenco and tango colour' },
{ type: 'sus2', tip: 'Remove 3rd entirely — ambiguous and floating' },
{ type: 'half_dim', tip: 'Flatten the 5th — half-diminished, much darker' },
],
dom7: [
{ type: 'maj7', tip: 'Raise the 7th — softer, much less tension' },
{ type: 'min7', tip: 'Lower the 3rd too — darkens the dominant' },
{ type: 'sus4', tip: 'Replace 3rd with 4th — funky unresolved suspension' },
{ type: 'dim7', tip: 'Diminished substitute — extreme tension before resolution' },
],
maj7: [
{ type: 'maj', tip: 'Simplify — strip back to triad, rawer feel' },
{ type: 'add9', tip: 'Drop 7th, add 9th — brighter and more open' },
{ type: 'maj6', tip: 'Swap 7th for 6th — retro jazz, less ethereal' },
{ type: 'dom7', tip: 'Flatten 7th — suddenly bluesy with tension' },
],
min7: [
{ type: 'min', tip: 'Strip back — rawer, more aggressive feel' },
{ type: 'half_dim', tip: 'Flatten the 5th — half-dim, darker and more tense' },
{ type: 'min6', tip: 'Add major 6th — sophisticated jazz minor colour' },
{ type: 'dom7', tip: 'Make it dominant — strong pull to resolve' },
],
dim: [
{ type: 'dim7', tip: 'Add dim7 — fully symmetric, equally unstable' },
{ type: 'half_dim', tip: 'Half-dim — softer, more melodic tension' },
{ type: 'min', tip: 'Simplify to minor — much less dissonant' },
],
dim7: [
{ type: 'dim', tip: 'Remove 7th — triad version, slightly less tense' },
{ type: 'half_dim', tip: 'Raise one note to half-dim — softer resolution' },
{ type: 'min7', tip: 'Raise ♭5 — from dark to smooth in one note' },
],
aug: [
{ type: 'maj', tip: 'Resolve the aug5 down to 5 — release the tension' },
{ type: 'dom7', tip: 'Add ♭7 over aug — double tension before a big resolution' },
],
sus4: [
{ type: 'maj', tip: 'Resolve — drop the 4th to the 3rd (classic sus → maj)' },
{ type: 'sus2', tip: 'Switch suspension — from 4th to 2nd, different feel' },
{ type: 'dom7', tip: 'Add ♭7 too — 7sus4, funky and unresolved' },
],
sus2: [
{ type: 'maj', tip: 'Fill in the 3rd — resolve the suspension clearly' },
{ type: 'sus4', tip: 'Swap 2nd for 4th — different flavour of openness' },
{ type: 'add9', tip: 'Add the 3rd back — sus2 becomes a richer add9' },
],
half_dim: [
{ type: 'dim7', tip: 'Add dim7 — more symmetric and tense' },
{ type: 'min7', tip: 'Raise the ♭5 to 5 — suddenly much smoother' },
{ type: 'min', tip: 'Strip back — simple minor triad' },
],
maj6: [
{ type: 'maj7', tip: 'Swap 6th for 7th — more modern jazz, more ethereal' },
{ type: 'add9', tip: 'Replace 6th with 9th — brighter, contemporary feel' },
],
min6: [
{ type: 'min7', tip: 'Swap 6th for 7th — smoother, less exotic' },
{ type: 'half_dim', tip: 'Enharmonic trick — min6 and half-dim share notes' },
],
add9: [
{ type: 'maj7', tip: 'Add the 7th — full maj9 sound, very lush' },
{ type: 'sus2', tip: 'Remove the 3rd — purely suspended' },
{ type: 'maj', tip: 'Strip the 9th — clean triad' },
],
}
// ─── Compute chord names for a famous progression in a given key ──────────────
export function progressionInKey(famousProg, keyRoot) {
const rootPc = NOTES.indexOf(keyRoot)
if (rootPc === -1) return []
return famousProg.degrees.map((d, i) => {
const pc = (rootPc + d) % 12
const type = famousProg.qualities[i] ?? 'maj'
return chordName(pc, type)
})
}
// Style variation chords in a given key
export function styleVariationInKey(styleVar, famousProg, keyRoot) {
if (!styleVar.qualities?.length) return []
const rootPc = NOTES.indexOf(keyRoot)
if (rootPc === -1) return []
return famousProg.degrees.slice(0, styleVar.qualities.length).map((d, i) => {
const pc = (rootPc + d) % 12
return chordName(pc, styleVar.qualities[i] ?? 'maj')
})
}
// ─── Similarity matching ──────────────────────────────────────────────────────
export function findSimilarProgressions(detectedChords, keyInfo) {
if (!detectedChords?.length || !keyInfo?.root) return []
const rootPc = NOTES.indexOf(keyInfo.root)
if (rootPc === -1) return []
// Convert detected chords to semitone offsets from key root
const detectedPcs = detectedChords
.map(c => { const p = parseChord(c); return p ? (p.rootPc - rootPc + 12) % 12 : null })
.filter(v => v !== null)
if (!detectedPcs.length) return []
const detectedSet = new Set(detectedPcs)
const results = []
for (const prog of FAMOUS_PROGRESSIONS) {
const progPcs = prog.degrees.map(d => d % 12)
const progSet = new Set(progPcs)
// Rotation match: does detected sequence appear in famous prog (as cyclic rotation)?
let rotScore = 0
const compareLen = Math.min(detectedPcs.length, progPcs.length)
for (let rot = 0; rot < progPcs.length; rot++) {
let hits = 0
for (let i = 0; i < compareLen; i++) {
if (detectedPcs[i] === progPcs[(rot + i) % progPcs.length]) hits++
}
rotScore = Math.max(rotScore, hits / compareLen)
}
// Jaccard similarity (chord-set overlap regardless of order)
const inter = [...detectedSet].filter(d => progSet.has(d)).length
const union = new Set([...detectedSet, ...progSet]).size
const jaccardScore = inter / union
const score = Math.max(rotScore, jaccardScore * 0.8)
if (score >= 0.35) results.push({ ...prog, score })
}
return results.sort((a, b) => b.score - a.score).slice(0, 4)
}
// ─── Substitutions for a chord name ──────────────────────────────────────────
export function getChordSubstitutions(chordName) {
const p = parseChord(chordName)
if (!p) return []
const subs = CHORD_SUBSTITUTIONS[p.type] ?? []
return subs.map(sub => ({
...sub,
chord: NOTES[p.rootPc] + (CHORD_TYPES[sub.type]?.suffix ?? ''),
}))
}
+563
View File
@@ -0,0 +1,563 @@
// ─── Guitar voicing shapes ────────────────────────────────────────────────────
// Open string pitches (standard tuning): E A D G B e
const OPEN = [4, 9, 2, 7, 11, 4] // pitch class per string [s6 … s1]
// Barre shape: offsets[] are fret distances from the root fret, per string [s6…s1].
// 'x' = muted. rootStr = 1-indexed string that holds the root (6=low E, 5=A…).
// Open shape: frets[] are absolute fret numbers (0=open, 'x'=muted) for a specific root key.
const GUITAR_SHAPES = {
maj: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,1,0,0], fingers: [1,3,4,2,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,2,2,0], fingers: [0,1,3,4,2,1], barre: { fromStr: 1, toStr: 5, fo: 0 } },
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,3,2], fingers: [0,0,1,3,4,2] },
{ label: 'G Shape', type: 'barre', rootStr: 6, offsets: [0,-1,-3,-3,-3,0], fingers: [4,3,1,1,1,4] },
{ label: 'Open E', type: 'open', onlyRoot: 4, frets: [0,2,2,1,0,0], fingers: [0,2,3,1,0,0] },
{ label: 'Open A', type: 'open', onlyRoot: 9, frets: ['x',0,2,2,2,0], fingers: [0,0,1,2,3,0] },
{ label: 'Open G', type: 'open', onlyRoot: 7, frets: [3,2,0,0,0,3], fingers: [2,1,0,0,0,3] },
{ label: 'Open C', type: 'open', onlyRoot: 0, frets: ['x',3,2,0,1,0], fingers: [0,3,2,0,1,0] },
{ label: 'Open D', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,2], fingers: [0,0,0,1,3,2] },
],
min: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,0,0,0], fingers: [1,3,4,1,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,2,1,0], fingers: [0,1,3,4,2,1], barre: { fromStr: 1, toStr: 5, fo: 0 } },
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,3,1], fingers: [0,0,1,3,4,2] },
{ label: 'Open Em', type: 'open', onlyRoot: 4, frets: [0,2,2,0,0,0], fingers: [0,2,3,0,0,0] },
{ label: 'Open Am', type: 'open', onlyRoot: 9, frets: ['x',0,2,2,1,0], fingers: [0,0,2,3,1,0] },
{ label: 'Open Dm', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,1], fingers: [0,0,0,2,3,1] },
],
dom7: [
{ label: 'E7 Barre', type: 'barre', rootStr: 6, offsets: [0,2,0,1,0,0], fingers: [1,3,0,2,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
{ label: 'A7 Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,0,2,0], fingers: [0,1,2,0,3,0] },
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,1,2], fingers: [0,0,1,3,2,4] },
{ label: 'Open E7', type: 'open', onlyRoot: 4, frets: [0,2,0,1,0,0], fingers: [0,2,0,1,0,0] },
{ label: 'Open A7', type: 'open', onlyRoot: 9, frets: ['x',0,2,0,2,0], fingers: [0,0,2,0,3,0] },
{ label: 'Open G7', type: 'open', onlyRoot: 7, frets: [3,2,0,0,0,1], fingers: [3,2,0,0,0,1] },
{ label: 'Open D7', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,1,2], fingers: [0,0,0,2,1,3] },
{ label: 'Open B7', type: 'open', onlyRoot: 11, frets: ['x',2,1,2,0,2], fingers: [0,2,1,3,0,4] },
],
maj7: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,1,1,0,0], fingers: [1,3,2,2,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,1,2,0], fingers: [0,1,3,2,4,1], barre: { fromStr: 1, toStr: 2, fo: 0 } },
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,2,2], fingers: [0,0,1,2,3,4], barre: { fromStr: 1, toStr: 3, fo: 2 } },
{ label: 'Open Cmaj7', type: 'open', onlyRoot: 0, frets: ['x',3,2,0,0,0], fingers: [0,3,2,0,0,0] },
{ label: 'Open Amaj7', type: 'open', onlyRoot: 9, frets: ['x',0,2,1,2,0], fingers: [0,0,2,1,3,0] },
{ label: 'Open Emaj7', type: 'open', onlyRoot: 4, frets: [0,2,1,1,0,0], fingers: [0,2,1,1,0,0] },
],
min7: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,0,0,0,0], fingers: [1,3,1,1,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,0,1,0], fingers: [0,1,3,1,2,1], barre: { fromStr: 1, toStr: 5, fo: 0 } },
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,1,1], fingers: [0,0,1,3,2,2] },
{ label: 'Open Em7', type: 'open', onlyRoot: 4, frets: [0,2,0,0,0,0], fingers: [0,2,0,0,0,0] },
{ label: 'Open Am7', type: 'open', onlyRoot: 9, frets: ['x',0,2,0,1,0], fingers: [0,0,2,0,1,0] },
{ label: 'Open Dm7', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,1,1], fingers: [0,0,0,3,1,2] },
],
dim: [
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,1,2,1,'x'], fingers: [0,1,2,4,3,0] },
{ label: 'Compact', type: 'barre', rootStr: 4, offsets: ['x','x',0,1,3,1], fingers: [0,0,1,2,4,3] },
],
dim7: [
{ label: 'Movable Box', type: 'barre', rootStr: 5, offsets: ['x',0,1,2,1,2], fingers: [0,1,2,4,3,4] },
{ label: 'Compact', type: 'barre', rootStr: 4, offsets: ['x','x',0,1,0,1], fingers: [0,0,1,2,3,4] },
],
aug: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,3,2,1,1,0], fingers: [1,4,3,2,2,1] },
{ label: 'Compact', type: 'barre', rootStr: 5, offsets: ['x',0,3,2,2,'x'], fingers: [0,1,4,2,3,0] },
],
sus4: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,2,0,0], fingers: [1,2,3,4,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,2,3,0], fingers: [0,1,2,3,4,0] },
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,3,3], fingers: [0,0,1,2,3,4] },
{ label: 'Open Asus4', type: 'open', onlyRoot: 9, frets: ['x',0,2,2,3,0], fingers: [0,0,1,2,4,0] },
{ label: 'Open Dsus4', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,3], fingers: [0,0,0,1,2,3] },
],
sus2: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,4,4,0,0], fingers: [1,2,4,4,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,4,0,0], fingers: [0,1,2,4,1,1], barre: { fromStr: 1, toStr: 2, fo: 0 } },
{ label: 'D Shape', type: 'barre', rootStr: 4, offsets: ['x','x',0,2,3,0], fingers: [0,0,1,2,3,0] },
{ label: 'Open Asus2', type: 'open', onlyRoot: 9, frets: ['x',0,2,2,0,0], fingers: [0,0,1,2,0,0] },
{ label: 'Open Dsus2', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,0], fingers: [0,0,0,1,3,0] },
],
half_dim: [
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,1,0,1,'x'], fingers: [0,1,2,0,3,0] },
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,1,2,0,0,'x'], fingers: [1,2,3,1,1,0], barre: { fromStr: 2, toStr: 6, fo: 0 } },
],
maj6: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,1,2,0], fingers: [1,3,4,2,4,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
{ label: 'A Barre', type: 'barre', rootStr: 5, offsets: ['x',0,2,2,2,2], fingers: [0,1,2,3,4,4], barre: { fromStr: 1, toStr: 2, fo: 2 } },
],
min6: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,2,0,2,0], fingers: [1,3,4,1,4,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
],
add9: [
{ label: 'E Barre', type: 'barre', rootStr: 6, offsets: [0,2,4,1,0,2], fingers: [1,2,4,3,1,1], barre: { fromStr: 1, toStr: 6, fo: 0 } },
{ label: 'Open Cadd9', type: 'open', onlyRoot: 0, frets: ['x',3,2,0,3,0], fingers: [0,3,2,0,4,0] },
{ label: 'Open Gadd9', type: 'open', onlyRoot: 7, frets: [3,2,0,2,3,3], fingers: [2,1,0,3,4,4] },
{ label: 'Open Dadd9', type: 'open', onlyRoot: 2, frets: ['x','x',0,2,3,0], fingers: [0,0,0,1,3,0] },
],
}
// ─── Piano techniques ─────────────────────────────────────────────────────────
// Each technique has intervals for left hand (LH) and right hand (RH) in semitones above root.
// Negative values go one octave below root. Labels appear on the keys.
export const PIANO_TECHNIQUES = {
maj: [
{
name: 'Full Chord',
desc: 'Classic voicing — root in left, triad in right',
tip: 'Anchor the root alone in your left hand, lock into the rhythm, let right hand sing.',
lh: [0],
rh: [0, 4, 7],
},
{
name: 'Root + 5th',
desc: 'Open, powerful — ambiguous major/minor quality',
tip: 'Stack octave + fifth in left hand. Works over major or minor — great for tense moments.',
lh: [0, 7],
rh: [0, 4, 7, 12],
},
{
name: 'Root + Octave',
desc: 'Thunderous low end — fills space in a band',
tip: 'Octave doubles in left hand, full chord in right. Huge sound in lower registers.',
lh: [0, 12],
rh: [4, 7, 12],
},
{
name: 'Spread Voicing',
desc: 'Wide, orchestral — two octaves apart',
tip: 'Split the chord wide: 5th in left, 3rd + 5th above the octave in right. Very cinematic.',
lh: [0, 7],
rh: [12, 16, 19],
},
{
name: 'Suspended Approach',
desc: 'Play sus4 → resolve to major — creates motion',
tip: 'Hit sus4 (replace 3rd with 4th) then release to the major 3rd. Works great in slow ballads.',
lh: [0],
rh: [0, 5, 7],
},
],
min: [
{
name: 'Full Chord',
desc: 'Classic minor voicing — dark and rich',
tip: 'The minor 3rd is everything. Let it ring — do not rush to resolve.',
lh: [0],
rh: [0, 3, 7],
},
{
name: 'Root + 5th',
desc: 'Open ambiguity — dramatic without the sadness',
tip: 'Power chord in both hands. Hides the minor quality — use when you want tension without gloom.',
lh: [0, 7],
rh: [0, 7, 12],
},
{
name: 'Root + Octave',
desc: 'Deep anchor — gives bass player space',
tip: 'Octave in left only. Right hand plays the minor chord high up for contrast.',
lh: [0, 12],
rh: [3, 7, 12],
},
{
name: 'Spread Minor',
desc: 'Atmospheric and wide — film score territory',
tip: 'Wide spacing on minor chords feels melancholic and vast. Popular in ambient and cinematic styles.',
lh: [0, 7],
rh: [12, 15, 19],
},
{
name: 'Minor + Add9',
desc: 'Add the 9th (2nd) — aching, bittersweet quality',
tip: 'Replace or add the 9th to minor. Radiohead, Portishead, modern soul — this interval is gold.',
lh: [0],
rh: [0, 3, 7, 14],
},
],
dom7: [
{
name: 'Full Dom7',
desc: 'Classic dominant — loaded with tension',
tip: 'The tritone between the 3rd and ♭7th creates all the tension. Let it ring before resolving.',
lh: [0],
rh: [0, 4, 7, 10],
},
{
name: 'Shell Voicing (1-3-♭7)',
desc: 'Skip the 5th — lean, jazz-approved',
tip: 'Root + major 3rd + ♭7th. The tritone is intact, 5th is redundant. Classic jazz comp technique.',
lh: [0],
rh: [4, 10],
},
{
name: 'Rootless Voicing',
desc: 'Advanced comping — let bass hold the root',
tip: 'No root in your hands at all. 3rd in left, upper structure in right. Very sophisticated sound.',
lh: [4],
rh: [7, 10, 14],
},
{
name: 'Blues Stomp',
desc: 'Root + 5th left, add ♭7 right — R&B classic',
tip: 'Left hand pumps root-5th pattern, right hand stabs the dominant 7th chord. Classic gospel/blues.',
lh: [0, 7],
rh: [0, 4, 10],
},
{
name: 'Tritone Sub',
desc: 'Replace with the chord a tritone away',
tip: 'G7 can be replaced with Db7 — they share the same tritone (F and B). Mind-bending jazz move.',
lh: [6],
rh: [10, 14, 16],
},
],
maj7: [
{
name: 'Full Maj7',
desc: 'Dreamy, floating — jazz and bossa nova',
tip: 'The major 7th creates a luminous, slightly unresolved quality. Do not over-play it — let it breathe.',
lh: [0],
rh: [0, 4, 7, 11],
},
{
name: 'Shell (1-3-7)',
desc: 'Root + 3rd + maj7 — lush and clean',
tip: 'Skip the 5th. The major 7th directly above the root defines the chord without clutter.',
lh: [0],
rh: [4, 11],
},
{
name: 'Spread Maj7',
desc: 'Maj7 in left hand — wide, orchestral texture',
tip: 'Place the 7th below the root (or an octave down). Creates a spacious, choir-like sound.',
lh: [0, 11],
rh: [12, 16, 19],
},
{
name: 'Add9 Variation',
desc: 'Add the 9th for extra colour',
tip: 'Maj9 territory. Remove the root in the right hand, add the 9th (D for Cmaj9). Very smooth.',
lh: [0],
rh: [4, 7, 11, 14],
},
],
min7: [
{
name: 'Full Min7',
desc: 'Smooth and mellow — soul and jazz workhorse',
tip: 'The minor 7th chord is the most versatile in jazz. Comp behind everything with this.',
lh: [0],
rh: [0, 3, 7, 10],
},
{
name: 'Shell (1-♭3-♭7)',
desc: 'Just the 3rd and 7th in right — spacious',
tip: 'Root in left, minor 3rd + minor 7th in right. Leaves maximum space for the soloist.',
lh: [0],
rh: [3, 10],
},
{
name: 'Rootless Min7',
desc: 'No root — upper structure only',
tip: '♭3rd in left, build up from there. Very advanced jazz voicing — trust the bass player.',
lh: [3],
rh: [10, 14, 15],
},
{
name: 'Spread Atmospheric',
desc: 'Wide voicing — ambient and cinematic',
tip: 'Minor 7ths voiced wide feel endless. Great for intro sections or building tension.',
lh: [0, 7],
rh: [10, 15, 19],
},
],
dim: [
{
name: 'Full Diminished',
desc: 'All three tones — tense and unstable',
tip: 'Always wants to resolve. Use as a passing chord between diatonic chords.',
lh: [0],
rh: [0, 3, 6],
},
{
name: 'Octave + Dim',
desc: 'Root octave in left — more weight',
tip: 'Dim triads are thin — doubling the root in left hand adds body.',
lh: [0, 12],
rh: [3, 6, 12],
},
],
dim7: [
{
name: 'Full Dim7',
desc: 'Symmetrical — repeats every 3 frets',
tip: 'Any note in a dim7 chord can be the root. It modulates effortlessly. Horror/drama gold.',
lh: [0],
rh: [0, 3, 6, 9],
},
{
name: 'Arpeggiated',
desc: 'Roll the notes — tension without crash',
tip: 'Roll from bottom to top quickly. Dim7 arpeggios feel like falling — use before a big resolve.',
lh: [0, 3],
rh: [6, 9, 12],
},
],
aug: [
{
name: 'Full Augmented',
desc: 'Dreamy, unresolved — whole-tone territory',
tip: 'Aug chords are symmetrical like dim7 — every inversion sounds the same. Very otherworldly.',
lh: [0],
rh: [0, 4, 8],
},
{
name: 'Spread Aug',
desc: 'Wide voicing — maximises the instability',
tip: 'Spread over two octaves. The raised 5th wants to resolve up — let the listener feel the pull.',
lh: [0, 8],
rh: [12, 16, 20],
},
],
sus4: [
{
name: 'Full Sus4',
desc: 'Suspended — neither major nor minor',
tip: 'Ambiguous and open. Resolve down to the major 3rd for instant satisfaction.',
lh: [0],
rh: [0, 5, 7],
},
{
name: 'Sus4 → Major',
desc: 'Play sus4 then resolve — creates motion',
tip: 'Hit sus4 on the beat, release to major on the off-beat. The oldest trick in the book.',
lh: [0],
rh: [5, 7, 12],
},
{
name: 'Root + 5th + Sus',
desc: 'Power chord with the fourth on top',
tip: 'Very rock and dramatic. The sus4 on top gives it an anthemic, U2-esque quality.',
lh: [0, 7],
rh: [7, 12, 17],
},
],
sus2: [
{
name: 'Full Sus2',
desc: 'Open, airy — the 2nd instead of 3rd',
tip: 'Sus2 chords feel free and unanchored. Great for intros and ambient sections.',
lh: [0],
rh: [0, 2, 7],
},
{
name: 'Spread Sus2',
desc: 'Wide and spacious — very ambient',
tip: 'The 2nd voiced wide feels like an open landscape. Sigur Rós territory.',
lh: [0, 7],
rh: [12, 14, 19],
},
],
half_dim: [
{
name: 'Full m7♭5',
desc: 'Half-diminished — minor 7th with flat 5',
tip: 'The ii chord in minor keys (e.g. Bø in C minor). Tense but smoother than full diminished.',
lh: [0],
rh: [0, 3, 6, 10],
},
{
name: 'Shell (1-♭3-♭7)',
desc: 'Skip the flat 5 — cleaner jazz comp',
tip: 'The ♭5 is optional in jazz. Root + minor 3rd + minor 7th is clean and functional.',
lh: [0],
rh: [3, 10],
},
],
maj6: [
{
name: 'Full Maj6',
desc: 'Major with added 6th — vintage jazz sound',
tip: 'Maj6 and min7 are inversions of each other. Interchangeable in many jazz contexts.',
lh: [0],
rh: [0, 4, 7, 9],
},
{
name: 'Shell (1-3-6)',
desc: 'Clean and retro — skip the 5th',
tip: 'The 6th adds sweetness without too much colour. Django Reinhardt loved this voicing.',
lh: [0],
rh: [4, 9],
},
],
min6: [
{
name: 'Full Min6',
desc: 'Minor with major 6th — exotic and dark',
tip: 'The major 6th over a minor triad is a flamenco and tango staple. Very striking colour.',
lh: [0],
rh: [0, 3, 7, 9],
},
{
name: 'Shell (1-♭3-6)',
desc: 'Tense and colourful — the ♭3+6 tension',
tip: 'The minor 3rd + major 6th interval is the characteristic clash of min6. Lean into it.',
lh: [0],
rh: [3, 9],
},
],
add9: [
{
name: 'Full Add9',
desc: 'Major chord + 9th — no 7th, stays bright',
tip: 'The 9th adds colour without the jazz sophistication of maj9. Feels modern and open.',
lh: [0],
rh: [0, 4, 7, 14],
},
{
name: 'No Root Add9',
desc: 'Skip root in right — 3rd + 9th float',
tip: 'Root in left, right hand plays 3rd + 5th + 9th. Airy and modern — Radiohead / Coldplay territory.',
lh: [0],
rh: [4, 7, 14],
},
{
name: 'Sus2-style',
desc: 'Add9 voiced as sus2 clusters',
tip: 'Place the 9th close to the root (2nd instead of 9th). Creates a shimmering cluster effect.',
lh: [0, 7],
rh: [2, 4, 7],
},
],
}
// Fallback for chord types not in the map
const GENERIC_PIANO = [
{
name: 'Full Chord',
desc: 'Root in left, chord tones in right',
tip: 'Play the chord tones in right hand while anchoring the root in the left.',
lh: [0],
rh: [0], // will be replaced by computed intervals
},
]
// ─── Compute functions ────────────────────────────────────────────────────────
// Parse a chord name like "C#m7" → { rootPc: 1, type: 'min7' }
// Mirrors the logic in theory.js parseChord
function parseChord(name) {
if (!name) return null
const NOTES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']
const NOTES_FLAT = ['C','Db','D','Eb','E','F','Gb','G','Ab','A','Bb','B']
const suffixMap = {
'': 'maj', 'm': 'min', 'min': 'min', 'maj': 'maj',
'7': 'dom7', 'maj7': 'maj7', 'm7': 'min7', 'min7': 'min7',
'dim': 'dim', 'dim7': 'dim7', 'm7b5': 'half_dim', 'ø': 'half_dim', 'ø7': 'half_dim',
'aug': 'aug', '+': 'aug',
'sus4': 'sus4', 'sus2': 'sus2',
'6': 'maj6', 'm6': 'min6', 'min6': 'min6',
'add9': 'add9',
}
let rest = name
let root = ''
if (rest.length > 1 && (rest[1] === '#' || rest[1] === 'b')) {
root = rest.slice(0, 2); rest = rest.slice(2)
} else {
root = rest.slice(0, 1); rest = rest.slice(1)
}
let rootPc = NOTES.indexOf(root)
if (rootPc === -1) rootPc = NOTES_FLAT.indexOf(root)
if (rootPc === -1) return null
const type = suffixMap[rest] ?? 'maj'
return { rootPc, type }
}
/**
* Returns an array of voicing objects for a given chord name.
* Each voicing: { label, frets[6], fingers[6], barre?, baseFret }
* frets values: number (fret) or 'x' (muted) or 0 (open)
*/
export function getGuitarVoicings(chordName) {
const parsed = parseChord(chordName)
if (!parsed) return []
const { rootPc, type } = parsed
const shapes = GUITAR_SHAPES[type] ?? GUITAR_SHAPES.maj
const result = []
for (const shape of shapes) {
if (shape.type === 'open') {
if (shape.onlyRoot !== undefined && shape.onlyRoot !== rootPc) continue
result.push({
label: shape.label,
frets: shape.frets,
fingers: shape.fingers,
barre: null,
baseFret: 1,
})
continue
}
// barre shape: compute rootFret on rootStr
const strIdx = shape.rootStr - 1 // 0=s6 … 5=s1
const openPc = OPEN[strIdx]
let rootFret = (rootPc - openPc + 12) % 12
// Compute absolute frets
const frets = shape.offsets.map((off, i) => {
if (off === 'x') return 'x'
return rootFret + off
})
// Skip impossible positions
if (frets.some(f => typeof f === 'number' && f < 0)) continue
const maxFret = Math.max(...frets.filter(f => f !== 'x'))
if (maxFret > 15) continue
// baseFret: start diagram so the chord fits in 5 frets
const minFret = Math.min(...frets.filter(f => f !== 'x' && f > 0))
const baseFret = rootFret === 0 ? 1 : Math.max(1, minFret)
// Compute barre position if applicable
let barre = null
if (shape.barre) {
barre = {
fret: rootFret + shape.barre.fo,
fromStr: shape.barre.fromStr,
toStr: shape.barre.toStr,
}
}
// For open-string E/A chords (rootFret=0), no barre needed
if (rootFret === 0) barre = null
result.push({
label: shape.label + (rootFret === 0 ? ' (Open)' : rootFret > 5 ? ` — Fret ${rootFret}` : ''),
frets,
fingers: shape.fingers,
barre,
baseFret,
})
}
return result
}
/**
* Returns piano techniques for a chord name.
* Each technique has: { name, desc, tip, lh (intervals), rh (intervals) }
*/
export function getPianoTechniques(chordName) {
const parsed = parseChord(chordName)
if (!parsed) return []
const { type } = parsed
return PIANO_TECHNIQUES[type] ?? GENERIC_PIANO
}
export { parseChord }