From 8cd9b01941fd15b3acc57ac0a010825856a682f9 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Wed, 18 Mar 2026 01:26:51 +0000 Subject: [PATCH 001/144] edu panel --- src/App.jsx | 23 ++ src/components/ChordBox.jsx | 137 +++++++ src/components/ChordDetailModal.jsx | 467 ++++++++++++++++++++++ src/components/CurrentJamPanel.jsx | 367 +++++++++++++++++ src/components/EducationPanel.jsx | 397 +++++++++++++++++++ src/components/ExplorePanel.jsx | 249 ++++++++++++ src/components/MiniPiano.jsx | 134 +++++++ src/components/ProgressionBanner.jsx | 27 +- src/components/RiffDiagram.jsx | 98 +++++ src/lib/education.js | 436 +++++++++++++++++++++ src/lib/voicings.js | 563 +++++++++++++++++++++++++++ 11 files changed, 2888 insertions(+), 10 deletions(-) create mode 100644 src/components/ChordBox.jsx create mode 100644 src/components/ChordDetailModal.jsx create mode 100644 src/components/CurrentJamPanel.jsx create mode 100644 src/components/EducationPanel.jsx create mode 100644 src/components/ExplorePanel.jsx create mode 100644 src/components/MiniPiano.jsx create mode 100644 src/components/RiffDiagram.jsx create mode 100644 src/lib/education.js create mode 100644 src/lib/voicings.js diff --git a/src/App.jsx b/src/App.jsx index 2e87c45..505ae95 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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() { )} + {/* ── Chord detail modal ── */} + setSelectedChord(null)} keyInfo={effectiveKey} chordHistory={chordHistory} /> + {/* ── Progression banner ── */} {/* ── Instrument + progressions row ── */} @@ -553,6 +561,21 @@ export default function App() { + {/* ── Explore any chord — collapsible ── */} + + + {/* ── Current jam — collapsible ── */} + + {/* ── Behind the scenes — collapsible ── */}
+ ) + })} +
+ + ) +} + +// ─── 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 ( +
+ + {/* ── Famous progressions starting from this chord ── */} +
+

+ Famous progressions — {chordName} as tonic +

+ {tonicProgs.length === 0 && ( +

No exact matches — try a major or minor chord.

+ )} +
+ {tonicProgs.slice(0, 6).map(prog => { + const chordsHere = progressionInKey(prog, root) + return ( +
+
+ {prog.name} + {prog.pattern} + {prog.genre.map(g => ( + {g} + ))} +
+ {/* Chord sequence */} +
+ {chordsHere.map((c, i) => ( + + + {i < chordsHere.length - 1 && } + + ))} +
+

{prog.description}

+ {prog.songs[0] && ( +

e.g. {prog.songs.slice(0, 3).join(' · ')}

+ )} +
+ ) + })} +
+
+ + {/* ── Genre-based next-chord suggestions ── */} + {genreProgs.length > 0 && ( +
+

+ Genre suggestions — starting from {chordName} +

+
+ {genreProgs.slice(0, 6).map((prog, pi) => ( +
+ {prog.genre} +
+ {prog.chords.map((c, i) => ( + + + {i < prog.chords.length - 1 && } + + ))} +
+ {prog.rn?.join(' – ')} +
+ ))} +
+
+ )} + + {/* ── Roles this chord plays ── */} + {ROLES.length > 0 && ( +
+

+ {chordName} appears in these keys +

+
+ {ROLES.slice(0, 8).map(({ keyRoot, mode, rn, diatonicChords }) => ( +
+
+ {keyRoot} + {mode} + {rn} +
+
+ {diatonicChords.map((c, i) => ( + + ))} +
+
+ ))} +
+
+ )} +
+ ) +} + +// ─── 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 ( +
+ + {/* ── Contextual quick-picks ── */} + {(recentChords.length > 0 || keyChords.length > 0) && ( +
+ + {keyChords.length > 0 && ( + <> + {recentChords.length > 0 &&
} + + + )} +
+ )} + + {/* ── Manual picker ── */} +
+ Root: +
+ {NOTES.map(n => ( + + ))} +
+
+ Type: +
+ + +
+
{chordName}
+
+ + {/* ── Sub-tabs ── */} +
+ {[ + { key: 'guitar', label: '🎸 Guitar' }, + { key: 'piano', label: '🎹 Piano' }, + { key: 'progressions', label: '🎵 Progressions' }, + ].map(t => ( + + ))} +
+ + {subTab === 'guitar' && } + {subTab === 'piano' && } + {subTab === 'progressions' && selectChord(c)} />} +
+ ) +} + +// ─── 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 ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+ + {/* Header */} +
+
+

{chord}

+

{typeName} chord · tap a voicing to study it

+
+ +
+ + {/* Tab bar */} +
+ {[ + { key: 'guitar', label: '🎸 Guitar Voicings' }, + { key: 'piano', label: '🎹 Piano Techniques' }, + { key: 'explore', label: '🔍 Explore Any Chord' }, + ].map(t => ( + + ))} +
+ + {/* Content */} +
+ {tab === 'guitar' && } + {tab === 'piano' && } + {tab === 'explore' && } +
+ +
+
+ ) +} diff --git a/src/components/CurrentJamPanel.jsx b/src/components/CurrentJamPanel.jsx new file mode 100644 index 0000000..563af0f --- /dev/null +++ b/src/components/CurrentJamPanel.jsx @@ -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: '1–2–3–5–6', 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: '1–2–3–4–5–6–♭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: '1–2–3–♯4–5–6–7', 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–♭3–4–5–♭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: '1–2–♭3–4–5–♭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: '1–2–♭3–4–5–6–♭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: '1–2–♭3–4–5–6–♭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–♭3–4–5–♭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–♭3–4–♭5–5–♭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: '1–2–3–4–5–6–♭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: '1–2–3–5–6', 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–♭3–3–4–5–♭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–♭3–4–5–♭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–♭2–3–4–5–♭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–♭3–4–5–♭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: '1–2–3–♯4–5–6–7', 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: '1–2–3–5–6', scaleIntervals: [0,2,4,7,9], desc: 'The reliable base. Use Lydian mode sparingly on top for colour.' }, + { name: 'Lydian Dominant', intervals: '1–2–3–♯4–5–6–♭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 ( +
+
+ + {rn && {rn}} + click for all voicings +
+ {voicings.length > 0 ? ( +
+ {voicings.map((v, i) => ( +
+ +

{v.label}

+
+ ))} +
+ ) : ( +

No voicings available.

+ )} +
+ ) +} + +// ─── Style variation section ────────────────────────────────────────────────── +function StyleSection({ progression, onChordClick }) { + const [expanded, setExpanded] = useState(null) + + return ( +
+ {STYLE_VARIATIONS.map(style => { + const isOpen = expanded === style.key + const transformed = progression.map(c => transformChord(c, style.typeMap)) + + return ( +
+ + + {isOpen && ( +
+
+ {transformed.map((c, i) => { + const voicings = getBestVoicings(c, 2) + return ( +
+ +
+ {voicings.map((v, vi) => ( +
+ +

{v.label}

+
+ ))} +
+
+ ) + })} +
+
+ )} +
+ ) + })} +
+ ) +} + +// ─── Similar famous progressions ───────────────────────────────────────────── +function SimilarSection({ progression, keyInfo, onChordClick }) { + const similar = findSimilarProgressions(progression, keyInfo) + if (!similar.length) return ( +

Play more and lock a key — similar progressions will appear here.

+ ) + return ( +
+ {similar.slice(0, 3).map(prog => { + const chordsHere = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : [] + return ( +
+
+ {prog.name} + {prog.pattern} + {Math.round(prog.score * 100)}% match +
+ {chordsHere.length > 0 && ( +
+ {chordsHere.map((c, i) => ( + + + {i < chordsHere.length - 1 && } + + ))} + in {keyInfo?.root} {keyInfo?.mode} +
+ )} +

{prog.songs.slice(0, 3).join(' · ')}

+
+ ) + })} +
+ ) +} + +// ─── 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 ( +
+ + + {open && ( +
+ + {!hasSession ? ( +

Start listening and play some chords — your jam will appear here.

+ ) : ( + <> + {/* ── Progression summary ── */} +
+ {root ? ( + {root} {mode} + ) : ( + Key detecting… + )} + {workingProgression.length > 0 && ( + <> + · + {workingProgression.map((c, i) => ( + + {c} + {root && {toRomanNumeral(c, root, mode)}} + {i < workingProgression.length - 1 && } + + ))} + + )} +
+ + {/* ── View tabs ── */} +
+ {[ + { key: 'voicings', label: '🎸 Open Voicings' }, + { key: 'scales', label: '🎵 Scales to Solo' }, + { key: 'styles', label: '🎨 Style Options' }, + { key: 'similar', label: '🔗 Similar Progressions' }, + ].map(t => ( + + ))} +
+ + {/* ── Voicings: per chord open shapes ── */} + {view === 'voicings' && ( +
+

+ Best open and barre voicings for each chord in your jam. Click a chord name to see all its voicings. +

+ {workingProgression.map(chord => ( + + ))} +
+ )} + + {/* ── Scales ── */} + {view === 'scales' && ( +
+

+ Scales and modes that fit {root ? `${root} ${mode}` : 'your current key'}. + Start with the pentatonic — add the extra notes once you feel comfortable. +

+ {scaleIdeas.map(idea => ( +
+
+ {idea.name} + {idea.intervals} +
+ {rootPc !== null && idea.scaleIntervals && ( +
+ +

+ Purple = root · Grey = scale tone · Fret numbers above +

+
+ )} +

{idea.desc}

+
+ ))} +

+ Pro tip: always resolve to a chord tone at the end of a phrase — ♭7 leading to root, or 3rd landing on the 1. +

+
+ )} + + {/* ── Style options ── */} + {view === 'styles' && ( +
+

+ Your progression re-voiced four ways. Expand any style to see the chord boxes. +

+ +
+ )} + + {/* ── Similar progressions ── */} + {view === 'similar' && ( + + )} + + )} +
+ )} +
+ ) +} diff --git a/src/components/EducationPanel.jsx b/src/components/EducationPanel.jsx new file mode 100644 index 0000000..7a3298c --- /dev/null +++ b/src/components/EducationPanel.jsx @@ -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 ( +
+
+

Key

+ {root ? ( +
+ {root} + {mode} + {confidence && {Math.round(confidence * 100)}%} +
+ ) : ( + Detecting… + )} +
+ +
+ +
+

Detected Loop

+ {detectedProgression?.length ? ( +
+ {detectedProgression.map((chord, i) => ( + + {chord} + {root && + {toRomanNumeral(chord, root, mode)} + } + + ))} +
+ ) : ( + None yet — keep playing! + )} +
+ +
+ +
+

Session

+

+ {totalPlayed} chords  ·  + {uniqueChords.length} unique +

+ {uniqueChords.length > 0 && ( +
+ {uniqueChords.slice(0, 10).map(c => ( + {c} + ))} + {uniqueChords.length > 10 && +{uniqueChords.length - 10}} +
+ )} +
+
+ ) +} + +// ─── Similar Progressions ───────────────────────────────────────────────────── +function SimilarProgressions({ similar, keyInfo, onChordClick }) { + const [expanded, setExpanded] = useState(null) + if (!similar.length) return ( +

+ Play more chords and lock a key to find similar famous progressions. +

+ ) + + return ( +
+ {similar.map(prog => { + const isOpen = expanded === prog.id + const chordsInKey = keyInfo?.root ? progressionInKey(prog, keyInfo.root) : [] + + return ( +
+ + {/* Header row */} + + ))} + in {keyInfo.root} {keyInfo.mode} +
+ )} +
+ {isOpen ? '▲' : '▼'} + + + {/* Expanded detail */} + {isOpen && ( +
+

{prog.description}

+ {prog.tip && ( +

+ Insight: {prog.tip} +

+ )} + + {/* Song examples */} +
+

Famous examples

+
+ {prog.songs.map(s => ( + {s} + ))} +
+
+ + {/* Style variations */} + {prog.styleVariations.length > 0 && ( +
+

Style variations

+
+ {prog.styleVariations.map(sv => { + const svChords = keyInfo?.root ? styleVariationInKey(sv, prog, keyInfo.root) : [] + return ( +
+ {sv.label} +
+ {sv.pattern} + {svChords.length > 0 && ( +
+ {svChords.map((c, i) => ( + + ))} + in {keyInfo.root} +
+ )} +
+
+ ) + })} +
+
+ )} +
+ )} +
+ ) + })} +
+ ) +} + +// ─── Play It Differently ────────────────────────────────────────────────────── +function PlayDifferently({ progression, onChordClick }) { + if (!progression?.length) return ( +

+ Keep playing — a repeating progression will appear here with substitution ideas. +

+ ) + + return ( +
+

+ Tap any substitution to see how to play it. These are harmonic replacements — same role, different colour. +

+ {progression.map(chord => { + const subs = getChordSubstitutions(chord) + return ( +
+ {/* Original chord */} + + + + + {/* Substitutions */} +
+ {subs.map(sub => ( +
+ + {/* Tooltip */} +
+ {sub.tip} +
+
+ ))} +
+
+ ) + })} +

+ Hover substitutions to see what they change · click to see voicings +

+
+ ) +} + +// ─── 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 ( +

+ Keep playing — your progression will appear here. +

+ ) + + return ( +
+

+ Your progression re-harmonised four ways. Click any chord to open its voicing explorer. +

+ {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 ( +
+
+ {row.label} + {row.desc} +
+
+ {progression.map((orig, i) => ( + + {orig} + + + {i < progression.length - 1 && ·} + + ))} +
+
+ ) + })} +
+ ) +} + +// ─── 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 ( +
+ + + {open && ( +
+ + {/* Section nav */} +
+ {[ + { 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 => ( + + ))} +
+ +
+ {section === 'snapshot' && ( + + )} + {section === 'similar' && ( + + )} + {section === 'play' && ( + + )} + {section === 'variations' && ( + + )} +
+
+ )} +
+ ) +} diff --git a/src/components/ExplorePanel.jsx b/src/components/ExplorePanel.jsx new file mode 100644 index 0000000..55bc780 --- /dev/null +++ b/src/components/ExplorePanel.jsx @@ -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 ( +
+ {label} +
+ {chords.map(chord => { + const rn = keyInfo?.root ? toRomanNumeral(chord, keyInfo.root, keyInfo.mode) : '' + return ( + + ) + })} +
+
+ ) +} + +// ─── Guitar voicings grid ───────────────────────────────────────────────────── +function GuitarGrid({ chordName }) { + const voicings = getGuitarVoicings(chordName) + if (!voicings.length) return

No voicings for {chordName}.

+ return ( +
+
+ {voicings.map((v, i) => ( +
+ +

{v.label}

+
+ ))} +
+

+ Purple = chord tone · finger numbers inside dots (1=index 4=pinky) · fret number on left if not starting at fret 1 +

+
+ ) +} + +// ─── Piano techniques grid ──────────────────────────────────────────────────── +function PianoGrid({ chordName }) { + const parsed = parseChord(chordName) + const techniques = getPianoTechniques(chordName) + const rootPc = parsed?.rootPc ?? 0 + if (!techniques.length) return

No techniques for {chordName}.

+ return ( +
+ {techniques.map((t, i) => ( +
+
+ +
+
+

{t.name}

+

{t.desc}

+

+ Tip: {t.tip} +

+
+
+ ))} +
+ ) +} + +// ─── 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 ( +
+

+ Famous progressions with {chordName} as the tonic. + Click any chord to see its voicings. +

+ {matching.map(prog => { + const chordsHere = progressionInKey(prog, root) + return ( +
+
+ {prog.name} + {prog.pattern} + {prog.genre.slice(0, 2).map(g => ( + {g} + ))} +
+
+ {chordsHere.map((c, i) => ( + + + {i < chordsHere.length - 1 && } + + ))} +
+

{prog.description}

+ {prog.songs.length > 0 && ( +

{prog.songs.slice(0, 3).join(' · ')}

+ )} +
+ ) + })} +
+ ) +} + +// ─── 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 ( +
+ + + {open && ( +
+ + {/* ── Context quick-picks ── */} + {(recentChords.length > 0 || keyChords.length > 0) && ( +
+ + {keyChords.length > 0 && recentChords.length > 0 &&
} + {keyChords.length > 0 && ( + + )} +
+ )} + + {/* ── Manual chord picker ── */} +
+
+ {NOTES.map(n => ( + + ))} +
+
+
+ + +
+
{chordName}
+
+ + {/* ── View tabs ── */} +
+ {[ + { key: 'guitar', label: '🎸 Guitar Voicings' }, + { key: 'piano', label: '🎹 Piano Techniques' }, + { key: 'progressions', label: '🎵 Progressions' }, + ].map(t => ( + + ))} +
+ + {/* ── Content ── */} + {view === 'guitar' && } + {view === 'piano' && } + {view === 'progressions' && { selectChord(c); onChordClick?.(c) }} />} + +
+ )} +
+ ) +} diff --git a/src/components/MiniPiano.jsx b/src/components/MiniPiano.jsx new file mode 100644 index 0000000..208c4c6 --- /dev/null +++ b/src/components/MiniPiano.jsx @@ -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 ( + + {/* White keys */} + {whites.map(({ x, hl, name, absWi }) => ( + + + {hl && ( + + {hl.label} + + )} + + ))} + + {/* Black keys */} + {blacks.map(({ x, pc, oct, hl }, i) => ( + + + {hl && ( + + {hl.label} + + )} + + ))} + + {/* Root label at bottom */} + {whites.map(({ x, pc, oct, name, absWi }) => { + const isRoot = pc === rootPc && oct === 0 + if (!isRoot) return null + return ( + + R + + ) + })} + + ) +} diff --git a/src/components/ProgressionBanner.jsx b/src/components/ProgressionBanner.jsx index 69d20ef..70c2a6a 100644 --- a/src/components/ProgressionBanner.jsx +++ b/src/components/ProgressionBanner.jsx @@ -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' }`} > 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' }`} > @@ -132,11 +134,16 @@ export default function ProgressionBanner({ chordHistory, keyInfo, detectedProgr {/* ── Right: big chord ── */}
{current ? ( - <> + ) : (

Play a chord

)} diff --git a/src/components/RiffDiagram.jsx b/src/components/RiffDiagram.jsx new file mode 100644 index 0000000..006e2a8 --- /dev/null +++ b/src/components/RiffDiagram.jsx @@ -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 ( + + {/* Fret separators (vertical lines) */} + {Array.from({ length: FRETS + 1 }, (_, f) => ( + + ))} + + {/* String lines (horizontal) */} + {Array.from({ length: 6 }, (_, s) => ( + + ))} + + {/* Fret numbers above */} + {Array.from({ length: FRETS }, (_, f) => ( + 0 ? '#a855f7' : '#555'} + fontWeight={f === 0 && startFret > 0 ? 'bold' : 'normal'}> + {startFret + f === 0 ? 'O' : startFret + f} + + ))} + + {/* Scale dots */} + {dots.map((d, i) => ( + + ))} + + {/* Root labels */} + {dots.filter(d => d.isRoot).map((d, i) => ( + + R + + ))} + + ) +} diff --git a/src/lib/education.js b/src/lib/education.js new file mode 100644 index 0000000..a28a69a --- /dev/null +++ b/src/lib/education.js @@ -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 ?? ''), + })) +} diff --git a/src/lib/voicings.js b/src/lib/voicings.js new file mode 100644 index 0000000..ae67cd1 --- /dev/null +++ b/src/lib/voicings.js @@ -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 } From f583979b3eaebbfca28bb940aa1f6ce64fc029a9 Mon Sep 17 00:00:00 2001 From: vadimwit Date: Fri, 20 Mar 2026 01:03:43 +0000 Subject: [PATCH 002/144] loop station --- src/App.jsx | 46 ++- src/components/AudioCapture.jsx | 5 +- src/components/ChordDetailModal.jsx | 354 ++++++++++++++++++- src/components/LoopSlot.jsx | 169 +++++++++ src/components/LoopStation.jsx | 508 ++++++++++++++++++++++++++++ src/components/LoopTrimmer.jsx | 337 ++++++++++++++++++ src/components/MusicTeacher.jsx | 410 ++++++++++++++++++++++ src/lib/education.js | 437 ++++++++++++++++++++++++ src/services/loopEngine.js | 359 ++++++++++++++++++++ 9 files changed, 2600 insertions(+), 25 deletions(-) create mode 100644 src/components/LoopSlot.jsx create mode 100644 src/components/LoopStation.jsx create mode 100644 src/components/LoopTrimmer.jsx create mode 100644 src/components/MusicTeacher.jsx create mode 100644 src/services/loopEngine.js diff --git a/src/App.jsx b/src/App.jsx index 505ae95..69f05ae 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -11,8 +11,9 @@ 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 LoopStation from './components/LoopStation' +import { useLoopEngine } from './services/loopEngine' import settingIcon from './assets/setting-icon.png' const DEFAULTS = { @@ -83,6 +84,23 @@ export default function App() { const onsetTimestampsRef = useRef([]) const bpmSmoothRef = useRef(null) + // ── Loop station ───────────────────────────────────────────────────────────── + const { + slots, + masterLen, + setStream: loopSetStream, + handleSlotClick, + commitTrim, + cancelRecord, + retrimSlot, + deleteSlot, + setVolume: loopSetVolume, + addSlot: loopAddSlot, + audioCtxRef: loopAudioCtxRef, + masterStartRef: loopMasterStartRef, + masterLenRef: loopMasterLenRef, + } = useLoopEngine(bpm) + // ── Key: auto-detected + optional lock ─────────────────────────────────────── const [keyInfo, setKeyInfo] = useState(null) // auto-detected const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null @@ -525,6 +543,7 @@ export default function App() { setMicError(true) setIsListening(false) }} + onStreamReady={loopSetStream} /> {micError && ( @@ -535,7 +554,7 @@ export default function App() { )} {/* ── Chord detail modal ── */} - setSelectedChord(null)} keyInfo={effectiveKey} chordHistory={chordHistory} /> + setSelectedChord(null)} onChordClick={setSelectedChord} keyInfo={effectiveKey} chordHistory={chordHistory} /> {/* ── Progression banner ── */}
- {/* ── Explore any chord — collapsible ── */} - {/* ── Current jam — collapsible ── */} + {/* ── Loop station ── */} + + {/* ── Behind the scenes — collapsible ── */}
- {subTab === 'guitar' && } - {subTab === 'piano' && } - {subTab === 'progressions' && selectChord(c)} />} + {subTab === 'guitar' && } + {subTab === 'piano' && }
) } // ─── Main modal ─────────────────────────────────────────────────────────────── -export default function ChordDetailModal({ chord, onClose, keyInfo, chordHistory }) { +export default function ChordDetailModal({ chord, onClose, onChordClick, keyInfo, chordHistory }) { const [tab, setTab] = useState('guitar') // Reset tab when chord changes @@ -436,15 +752,18 @@ export default function ChordDetailModal({ chord, onClose, keyInfo, chordHistory
{/* Tab bar */} -
+
{[ - { key: 'guitar', label: '🎸 Guitar Voicings' }, - { key: 'piano', label: '🎹 Piano Techniques' }, - { key: 'explore', label: '🔍 Explore Any Chord' }, + { key: 'guitar', label: '🎸 Guitar' }, + { key: 'piano', label: '🎹 Piano' }, + { key: 'theory', label: '📚 Theory' }, + { key: 'learn', label: '🎓 Learn' }, + { key: 'progressions', label: '🎵 Progressions' }, + { key: 'explore', label: '🔍 Explore' }, ].map(t => (
diff --git a/src/components/LoopSlot.jsx b/src/components/LoopSlot.jsx new file mode 100644 index 0000000..2992020 --- /dev/null +++ b/src/components/LoopSlot.jsx @@ -0,0 +1,169 @@ +import { useRef, useEffect, useState, useCallback } from 'react' + +const STYLE = { + empty: { border: 'border-border', bg: 'bg-surface', icon: '●', iconColor: 'text-gray-700' }, + recording: { border: 'border-red-500', bg: 'bg-red-950/20', icon: '⏺', iconColor: 'text-red-400' }, + trimming: { border: 'border-amber-400', bg: 'bg-amber-950/20', icon: '✂', iconColor: 'text-amber-400'}, + playing: { border: 'border-accent', bg: 'bg-accent/10', icon: '▶', iconColor: 'text-accent' }, + muted: { border: 'border-border', bg: 'bg-surface', icon: '⏸', iconColor: 'text-gray-500' }, +} + +const LABEL = { + empty: 'tap to rec', + recording: 'tap to stop', + trimming: 'trimming…', + playing: 'tap to mute', + muted: 'tap to play', +} + +export default function LoopSlot({ slot, slotIdx, audioCtxRef, masterStartRef, masterLenRef, onClick, onRetrim, onDelete, onVolumeChange }) { + const progressRef = useRef(null) + const rafRef = useRef(null) + const [showVol, setShowVol] = useState(false) + const [holdTimer, setHoldTimer] = useState(null) + const [deleting, setDeleting] = useState(false) + + const { status, recordingDuration, volume, originalBuffer } = slot + const style = STYLE[status] ?? STYLE.empty + const isActive = status === 'playing' || status === 'muted' + + // ── Progress bar via rAF ───────────────────────────────────────────────── + useEffect(() => { + if (!isActive) { + if (progressRef.current) progressRef.current.style.width = '0%' + return + } + function tick() { + const ctx = audioCtxRef.current + const mStart = masterStartRef.current + const mLen = masterLenRef.current + if (ctx && mStart !== null && mLen && progressRef.current) { + const pos = ((ctx.currentTime - mStart) % mLen) / mLen * 100 + progressRef.current.style.width = `${pos}%` + } + rafRef.current = requestAnimationFrame(tick) + } + rafRef.current = requestAnimationFrame(tick) + return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) } + }, [isActive, audioCtxRef, masterStartRef, masterLenRef]) + + // ── Long-press to delete ────────────────────────────────────────────────── + const onPointerDown = useCallback((e) => { + e.preventDefault() + const t = setTimeout(() => setDeleting(true), 500) + setHoldTimer(t) + }, []) + + const onPointerUp = useCallback(() => { + if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) } + if (!deleting) onClick(slotIdx) + }, [holdTimer, deleting, onClick, slotIdx]) + + const onPointerLeave = useCallback(() => { + if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) } + }, [holdTimer]) + + const confirmDelete = useCallback(() => { + setDeleting(false) + onDelete(slotIdx) + }, [onDelete, slotIdx]) + + return ( +
+ + {/* Delete confirmation overlay (long-press) */} + {deleting && ( +
+ + +
+ )} + + {/* Quick clear button — visible on non-empty slots */} + {status !== 'empty' && !deleting && ( + + )} + + {/* Main button */} + + + {/* Controls row (playing/muted only) */} + {isActive && ( +
+ {/* Volume toggle */} + + {showVol && ( + onVolumeChange(slotIdx, parseFloat(e.target.value))} + className="w-12 h-1 cursor-pointer accent-purple-500" + /> + )} + {/* Re-trim button — only when original recording exists */} + {originalBuffer && ( + + )} +
+ )} + + {/* Status label */} + + {LABEL[status] ?? ''} + +
+ ) +} diff --git a/src/components/LoopStation.jsx b/src/components/LoopStation.jsx new file mode 100644 index 0000000..515e2f6 --- /dev/null +++ b/src/components/LoopStation.jsx @@ -0,0 +1,508 @@ +import { useState, useRef, useEffect } from 'react' +import LoopTrimmer from './LoopTrimmer' + +const H_TRACK = 56 // track canvas height px +const H_MASTER = 26 // master timeline height px + +// ── Canvas draw helpers ─────────────────────────────────────────────────────── + +function drawGrid(canvas, totalSec, bpm) { + const rect = canvas.getBoundingClientRect() + if (!rect.width || !rect.height) return + const dpr = window.devicePixelRatio ?? 1 + canvas.width = rect.width * dpr + canvas.height = rect.height * dpr + const ctx = canvas.getContext('2d') + ctx.scale(dpr, dpr) + const W = rect.width, H = rect.height + + ctx.fillStyle = '#0f0f0f' + ctx.fillRect(0, 0, W, H) + if (!bpm || !totalSec) return + + const beatSec = 60 / bpm + const barSec = beatSec * 4 + + // Beat lines + ctx.strokeStyle = 'rgba(255,255,255,0.06)' + ctx.lineWidth = 1 + for (let t = beatSec; t < totalSec; t += beatSec) { + if ((t % barSec) < beatSec * 0.4) continue + const x = (t / totalSec) * W + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() + } + // Bar lines + numbers + for (let t = 0; t <= totalSec; t += barSec) { + ctx.strokeStyle = 'rgba(168,85,247,0.45)' + ctx.lineWidth = 1.5 + const x = (t / totalSec) * W + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() + const n = Math.round(t / barSec) + if (n > 0) { + ctx.fillStyle = 'rgba(168,85,247,0.55)' + ctx.font = '9px monospace' + ctx.textAlign = 'left' + ctx.fillText(String(n), x + 3, H - 2) + } + } +} + +function drawWaveform(canvas, waveform, muted) { + const rect = canvas.getBoundingClientRect() + if (!rect.width || !rect.height) return + const dpr = window.devicePixelRatio ?? 1 + canvas.width = rect.width * dpr + canvas.height = rect.height * dpr + const ctx = canvas.getContext('2d') + ctx.scale(dpr, dpr) + const W = rect.width, H = rect.height + + ctx.fillStyle = '#0f0f0f' + ctx.fillRect(0, 0, W, H) + + const N = waveform.length + const mid = H / 2 + for (let i = 0; i < N; i++) { + const x = (i / N) * W + const barW = Math.max(1, W / N - 0.3) + ctx.fillStyle = muted ? '#3b1f55' : '#a855f7' + const h = waveform[i] * mid * 0.85 + ctx.fillRect(x, mid - h, barW, h * 2) + } +} + +function drawRecording(canvas, duration, bpm) { + const rect = canvas.getBoundingClientRect() + if (!rect.width || !rect.height) return + const dpr = window.devicePixelRatio ?? 1 + canvas.width = rect.width * dpr + canvas.height = rect.height * dpr + const ctx = canvas.getContext('2d') + ctx.scale(dpr, dpr) + const W = rect.width, H = rect.height + + ctx.fillStyle = '#0f0f0f' + ctx.fillRect(0, 0, W, H) + + const beatSec = bpm ? 60 / bpm : null + const barSec = beatSec ? beatSec * 4 : null + const viewDur = barSec + ? Math.max(barSec * 4, Math.ceil(duration / barSec + 1) * barSec) + : Math.max(8, duration * 1.4) + + // Grid + if (beatSec) { + ctx.strokeStyle = 'rgba(255,255,255,0.06)' + ctx.lineWidth = 1 + for (let t = beatSec; t < viewDur; t += beatSec) { + if (barSec && (t % barSec) < beatSec * 0.4) continue + const x = (t / viewDur) * W + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() + } + if (barSec) { + for (let t = barSec; t <= viewDur; t += barSec) { + ctx.strokeStyle = 'rgba(239,68,68,0.3)' + ctx.lineWidth = 1.5 + const x = (t / viewDur) * W + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() + const n = Math.round(t / barSec) + ctx.fillStyle = 'rgba(239,68,68,0.45)' + ctx.font = '9px monospace' + ctx.textAlign = 'left' + ctx.fillText(String(n), x + 2, H - 2) + } + } + } + + // Growing fill + cursor + const fillX = (duration / viewDur) * W + ctx.fillStyle = 'rgba(239,68,68,0.18)' + ctx.fillRect(0, 0, fillX, H) + ctx.strokeStyle = 'rgba(239,68,68,0.85)' + ctx.lineWidth = 1.5 + ctx.beginPath(); ctx.moveTo(fillX, 0); ctx.lineTo(fillX, H); ctx.stroke() + + // Counter label + const bars = barSec ? Math.floor(duration / barSec) + 1 : null + const label = bars !== null + ? `● REC BAR ${bars} ${duration.toFixed(1)}s — tap to stop` + : `● REC ${duration.toFixed(1)}s — tap to stop` + ctx.fillStyle = 'rgba(239,68,68,0.9)' + ctx.font = 'bold 11px monospace' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText(label, W / 2, H / 2) +} + +// ── Master Timeline ─────────────────────────────────────────────────────────── + +function MasterTimeline({ masterStartRef, masterLenRef, audioCtxRef, bpm, masterLen }) { + const canvasRef = useRef(null) + const playheadRef = useRef(null) + const rafRef = useRef(null) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + const id = requestAnimationFrame(() => drawGrid(canvas, masterLen, bpm)) + return () => cancelAnimationFrame(id) + }, [masterLen, bpm]) + + useEffect(() => { + if (!masterLen) { cancelAnimationFrame(rafRef.current); return } + function tick() { + const ac = audioCtxRef.current + const t0 = masterStartRef.current + const len = masterLenRef.current + if (ac && t0 !== null && len && playheadRef.current) { + const pos = ((ac.currentTime - t0) % len) / len + playheadRef.current.style.left = `${pos * 100}%` + } + rafRef.current = requestAnimationFrame(tick) + } + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) + }, [masterLen, audioCtxRef, masterStartRef, masterLenRef]) + + return ( +
+ + {!masterLen && ( +
+ + record first loop to set master length + +
+ )} + {masterLen && ( +
+ )} +
+ ) +} + +// ── Track Row ───────────────────────────────────────────────────────────────── + +function TrackRow({ slot, slotIdx, bpm, audioCtxRef, masterStartRef, masterLenRef, + onSlotClick, onRetrim, onDelete, onVolumeChange }) { + const [showVol, setShowVol] = useState(false) + const canvasRef = useRef(null) + const playheadRef = useRef(null) + const rafRef = useRef(null) + + const DOT_CLASS = { + empty: 'bg-gray-700', + recording: 'bg-red-500 animate-pulse', + trimming: 'bg-amber-500', + playing: 'bg-accent', + muted: 'bg-gray-500', + } + const BORDER_CLASS = { + empty: 'border-border', + recording: 'border-red-800', + trimming: 'border-amber-800/60', + playing: 'border-accent/40', + muted: 'border-border', + } + + const dotClass = DOT_CLASS[slot.status] ?? 'bg-gray-700' + const borderClass = BORDER_CLASS[slot.status] ?? 'border-border' + + // Draw waveform when data arrives or mute state changes + useEffect(() => { + if (!slot.waveform) return + if (slot.status === 'recording' || slot.status === 'trimming') return + const canvas = canvasRef.current + if (!canvas) return + const id = requestAnimationFrame(() => + drawWaveform(canvas, slot.waveform, slot.status === 'muted') + ) + return () => cancelAnimationFrame(id) + }, [slot.waveform, slot.status]) + + // Draw recording progress on each duration tick + useEffect(() => { + if (slot.status !== 'recording') return + const canvas = canvasRef.current + if (!canvas) return + drawRecording(canvas, slot.recordingDuration, bpm) + }, [slot.recordingDuration, slot.status, bpm]) + + // Playhead animation + useEffect(() => { + const active = slot.status === 'playing' || slot.status === 'muted' + if (!active) { + cancelAnimationFrame(rafRef.current) + if (playheadRef.current) playheadRef.current.style.left = '-2px' + return + } + function tick() { + const ac = audioCtxRef.current + const t0 = masterStartRef.current + const len = masterLenRef.current + if (ac && t0 !== null && len && playheadRef.current) { + const pos = ((ac.currentTime - t0) % len) / len + playheadRef.current.style.left = `${pos * 100}%` + } + rafRef.current = requestAnimationFrame(tick) + } + rafRef.current = requestAnimationFrame(tick) + return () => cancelAnimationFrame(rafRef.current) + }, [slot.status, audioCtxRef, masterStartRef, masterLenRef]) + + const isClickable = slot.status !== 'trimming' + const isActive = slot.status === 'playing' || slot.status === 'muted' + + return ( +
+ + {/* Left: tap button (state dot + track number) */} + + + {/* Canvas: waveform / recording / empty */} +
isClickable && onSlotClick(slotIdx)} + > + + + {slot.status === 'empty' && ( +
+ tap to record +
+ )} + {slot.status === 'trimming' && ( +
+ trimming ↓ +
+ )} + + {/* Moving playhead */} + {isActive && ( +
+ )} +
+ + {/* Right: controls */} +
+ {showVol && ( + onVolumeChange(slotIdx, parseFloat(e.target.value))} + className="w-14 accent-purple-500" + title="Volume" + /> + )} + + {isActive && slot.originalBuffer && ( + + )} + +
+
+ ) +} + +// ── Main ────────────────────────────────────────────────────────────────────── + +export default function LoopStation({ + slots, + bpm, + masterLen, + audioCtxRef, + masterStartRef, + masterLenRef, + onSlotClick, + onCommitTrim, + onCancelRecord, + onRetrim, + onDelete, + onVolumeChange, + onAddSlot, +}) { + const [open, setOpen] = useState(false) + const [gridBpm, setGridBpm] = useState(bpm ?? '') + + // Pre-fill BPM when detection arrives + useEffect(() => { + if (bpm && !gridBpm) setGridBpm(bpm) + }, [bpm]) // eslint-disable-line react-hooks/exhaustive-deps + + const gridBpmNum = parseFloat(gridBpm) || null + const trimmingIdx = slots.findIndex(s => s.status === 'trimming') + + const recordingCount = slots.filter(s => s.status === 'recording').length + const playingCount = slots.filter(s => s.status === 'playing').length + + const dotClass = recordingCount > 0 + ? 'bg-red-500 animate-pulse' + : playingCount > 0 + ? 'bg-accent' + : 'bg-gray-700' + + const masterLabel = (() => { + if (!masterLen) return null + if (gridBpmNum) { + const bars = Math.round(masterLen / ((60 / gridBpmNum) * 4)) + return `${bars} bar${bars !== 1 ? 's' : ''} · ${masterLen.toFixed(2)}s` + } + return masterLen.toFixed(2) + 's' + })() + + return ( +
+ {/* ── Header ──────────────────────────────────────────────────────────── */} +
+ + +
+
+ BPM + setGridBpm(e.target.value)} + placeholder={bpm ? String(Math.round(bpm)) : '—'} + className="w-10 bg-transparent text-xs text-gray-300 text-center focus:outline-none focus:text-white" + style={{ MozAppearance: 'textfield' }} + /> +
+ +
+
+ + {/* ── Body ────────────────────────────────────────────────────────────── */} + {open && ( +
+ + {/* Master timeline */} + + + {/* Track rows */} +
+ {slots.map((slot, i) => ( + + ))} +
+ + {/* Add track */} +
+ +
+ + {/* LoopTrimmer — shown below tracks when trimming */} + {trimmingIdx !== -1 && ( + + )} +
+ )} +
+ ) +} diff --git a/src/components/LoopTrimmer.jsx b/src/components/LoopTrimmer.jsx new file mode 100644 index 0000000..af3f983 --- /dev/null +++ b/src/components/LoopTrimmer.jsx @@ -0,0 +1,337 @@ +import { useRef, useState, useEffect, useCallback } from 'react' + +function fmtMs(sec) { + return `${(sec * 1000).toFixed(0)}ms` +} + +function fmtSec(sec) { + return sec < 10 ? `${sec.toFixed(2)}s` : `${sec.toFixed(1)}s` +} + +export default function LoopTrimmer({ slot, slotIdx, bpm, audioCtxRef, onCommit, onCancel }) { + const canvasRef = useRef(null) + const containerRef = useRef(null) + const previewRef = useRef(null) // AudioBufferSourceNode for preview + + const [trimStart, setTrimStart] = useState(slot.trimStart) + const [trimEnd, setTrimEnd] = useState(slot.trimEnd) + const [previewing, setPreviewing] = useState(false) + + // Refs so drag closures always have current values + const trimStartRef = useRef(trimStart) + const trimEndRef = useRef(trimEnd) + useEffect(() => { trimStartRef.current = trimStart }, [trimStart]) + useEffect(() => { trimEndRef.current = trimEnd }, [trimEnd]) + + const duration = slot.audioBuffer?.duration ?? 0 + const startSec = trimStart * duration + const endSec = trimEnd * duration + const selectedSec = endSec - startSec + + // Beat grid info — visual reference only, no snapping + const beatSec = bpm ? 60 / bpm : null + const barSec = beatSec ? beatSec * 4 : null + + // Stop preview when handles change + useEffect(() => { + if (previewing) stopPreview() + }, [trimStart, trimEnd]) // eslint-disable-line react-hooks/exhaustive-deps + + // Cleanup on unmount + useEffect(() => { + return () => stopPreview() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + function stopPreview() { + try { previewRef.current?.stop() } catch {} + previewRef.current = null + setPreviewing(false) + } + + function togglePreview() { + if (previewing) { stopPreview(); return } + const ctx = audioCtxRef?.current + const buf = slot.audioBuffer + if (!ctx || !buf) return + + // Resume context if suspended + if (ctx.state === 'suspended') ctx.resume().catch(() => {}) + + const sr = buf.sampleRate + const startSample = Math.floor(trimStartRef.current * buf.length) + const endSample = Math.ceil(trimEndRef.current * buf.length) + const len = Math.max(1, endSample - startSample) + const data = buf.getChannelData(0).slice(startSample, endSample) + + const previewBuf = ctx.createBuffer(1, len, sr) + previewBuf.copyToChannel(data, 0) + + const node = ctx.createBufferSource() + node.buffer = previewBuf + node.loop = true + node.loopStart = 0 + node.loopEnd = len / sr + node.connect(ctx.destination) + node.start() + node.onended = () => { previewRef.current = null; setPreviewing(false) } + + previewRef.current = node + setPreviewing(true) + } + + // Snap end handle to N bars from current start + function snapBars(n) { + if (!barSec || !duration) return + const newEnd = Math.min(1, trimStartRef.current + (n * barSec) / duration) + setTrimEnd(newEnd) + trimEndRef.current = newEnd + draw() + } + + // ── Canvas draw ───────────────────────────────────────────────────────────── + const draw = useCallback(() => { + const canvas = canvasRef.current + if (!canvas || !slot.waveform) return + const rect = canvas.getBoundingClientRect() + if (rect.width === 0) return + const dpr = window.devicePixelRatio ?? 1 + canvas.width = rect.width * dpr + canvas.height = rect.height * dpr + const ctx = canvas.getContext('2d') + ctx.scale(dpr, dpr) + const W = rect.width + const H = rect.height + const wf = slot.waveform + const N = wf.length + const ts = trimStartRef.current + const te = trimEndRef.current + + // Background + ctx.fillStyle = '#0f0f0f' + ctx.fillRect(0, 0, W, H) + + // Dim regions outside selection + ctx.fillStyle = 'rgba(0,0,0,0.6)' + ctx.fillRect(0, 0, ts * W, H) + ctx.fillRect(te * W, 0, W - te * W, H) + + // Beat grid — visual only, beat lines then bar lines (bars on top) + if (beatSec && duration) { + // Beat lines + ctx.strokeStyle = 'rgba(255,255,255,0.10)' + ctx.lineWidth = 1 + for (let t = 0; t <= duration; t += beatSec) { + const isBar = barSec ? (t % barSec) < beatSec * 0.4 : false + if (!isBar) { + const x = (t / duration) * W + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() + } + } + // Bar lines (brighter, thicker) + if (barSec) { + ctx.strokeStyle = 'rgba(168,85,247,0.55)' + ctx.lineWidth = 1.5 + for (let t = 0; t <= duration; t += barSec) { + const x = (t / duration) * W + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() + // Bar number label + const barNum = Math.round(t / barSec) + if (barNum > 0) { + ctx.fillStyle = 'rgba(168,85,247,0.5)' + ctx.font = `${9 * dpr / dpr}px monospace` + ctx.fillText(`${barNum}`, x + 3, 10) + } + } + } + } + + // Waveform bars + const mid = H / 2 + for (let i = 0; i < N; i++) { + const x = (i / N) * W + const barW = Math.max(1, W / N - 0.5) + const inSel = (i / N) >= ts && (i / N) <= te + ctx.fillStyle = inSel ? '#a855f7' : '#3b0764' + const h = wf[i] * mid * 0.88 + ctx.fillRect(x, mid - h, barW, h * 2) + } + + // Handle lines + ctx.strokeStyle = '#a855f7' + ctx.lineWidth = 2 + ctx.beginPath(); ctx.moveTo(ts * W, 0); ctx.lineTo(ts * W, H); ctx.stroke() + ctx.beginPath(); ctx.moveTo(te * W, 0); ctx.lineTo(te * W, H); ctx.stroke() + }, [slot.waveform, beatSec, barSec, duration]) + + useEffect(() => { draw() }, [draw, trimStart, trimEnd]) + useEffect(() => { + const id = requestAnimationFrame(() => draw()) + return () => cancelAnimationFrame(id) + }, [draw]) + + // ── Pointer → fraction ────────────────────────────────────────────────────── + function fracFromClientX(clientX) { + const el = containerRef.current + if (!el) return 0 + const rect = el.getBoundingClientRect() + return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) + } + + // ── Drag handles — free movement, no snapping ─────────────────────────────── + function handleMouseDown(handle) { + return (e) => { + e.preventDefault() + function onMove(ev) { + const raw = fracFromClientX(ev.clientX) + if (handle === 'start') { + const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01)) + setTrimStart(c); trimStartRef.current = c + } else { + const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw)) + setTrimEnd(c); trimEndRef.current = c + } + draw() + } + function onUp() { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + } + } + + // Click canvas to move nearest handle + function handleCanvasClick(e) { + if (e.target !== canvasRef.current) return + const raw = fracFromClientX(e.clientX) + if (Math.abs(raw - trimStart) <= Math.abs(raw - trimEnd)) { + const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01)) + setTrimStart(c); trimStartRef.current = c + } else { + const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw)) + setTrimEnd(c); trimEndRef.current = c + } + draw() + } + + return ( +
+ {/* Header row */} +
+ + Trim — Loop {slotIdx + 1} + +
+ {fmtMs(startSec)} → {fmtMs(endSec)} + {fmtSec(selectedSec)} +
+
+ + {/* Waveform + handles */} +
+ + + {/* Left handle */} +
e.stopPropagation()} + > +
+
+
+ {fmtMs(startSec)} +
+
+ + {/* Right handle */} +
e.stopPropagation()} + > +
+
+
+ {fmtMs(endSec)} +
+
+
+ + {/* Toolbar: preview + bar snap + hint */} +
+ {/* Preview play/stop */} + + + {/* Bar snap buttons — only if BPM is set */} + {barSec && duration && ( +
+ snap end → + {[1, 2, 4].map(n => { + const endFrac = trimStart + (n * barSec) / duration + const fits = endFrac <= 1.02 + return ( + + ) + })} +
+ )} + + + {bpm ? `${bpm} BPM grid` : 'no BPM — trim freely'} + +
+ + {/* Action buttons */} +
+ + + +
+
+ ) +} diff --git a/src/components/MusicTeacher.jsx b/src/components/MusicTeacher.jsx new file mode 100644 index 0000000..b68dba2 --- /dev/null +++ b/src/components/MusicTeacher.jsx @@ -0,0 +1,410 @@ +import { useState, useRef, useEffect, useCallback } from 'react' + +const MODEL = 'claude-sonnet-4-6' +const API_URL = 'https://api.anthropic.com/v1/messages' +const LS_KEY = 'wtf_teacher_key' + +// ── System prompt — rebuilt with live session context on every request ──────── +function buildSystemPrompt({ keyInfo, currentChord, bpm, chordHistory }) { + const keyStr = keyInfo ? `${keyInfo.root} ${keyInfo.mode}` : 'not detected yet' + const chordStr = currentChord?.name ?? 'none detected' + const bpmStr = bpm ? `${Math.round(bpm)} BPM` : 'not detected' + const histStr = chordHistory?.length + ? chordHistory.map(c => c.name).join(' → ') + : 'none yet' + + return `You are an expert music teacher and session musician embedded in JamBuddy, a real-time chord and key detection app for guitarists and keyboard players at live jam sessions. + +LIVE SESSION CONTEXT (updated in real time): +• Detected key: ${keyStr} +• Current chord: ${chordStr} +• BPM: ${bpmStr} +• Recent chord history: ${histStr} + +YOUR ROLE: +- Explain chords, scales, and music theory in plain, friendly language +- Suggest what to practice based on the current key and chord progression +- Teach playing techniques: fretting, strumming patterns, chord voicings, fingerpicking +- Help musicians understand WHY things sound the way they do +- Suggest progressions that work with whatever the user is currently playing +- Adjust depth to the user — explain basics if they seem new, go deep if they ask for it +- Point out interesting connections: "that Dm7 works here because it's the ii chord in C major" + +STYLE: +- Keep responses focused and practical — this is a live jam, not a classroom +- Use plain text, not markdown. Short paragraphs. Bullet points with "-" are fine. +- If someone asks about the current chord or key, use the live context above +- Max ~150 words unless someone asks for a deep dive` +} + +// ── Quick-action chips ──────────────────────────────────────────────────────── +const CHIPS = [ + { label: 'What should I practice?', msg: 'Based on what I\'m playing right now, what\'s the most useful thing I could practice?' }, + { label: 'Explain current chord', msg: 'Explain the current chord I\'m playing — what it is, why it sounds the way it does, and where it tends to appear.' }, + { label: 'Scales that work here', msg: 'What scales work over the current key and chord? Which notes sound best to improvise with?' }, + { label: 'Suggest a progression', msg: 'Suggest a chord progression that fits the current key. Give me something interesting to try.' }, + { label: 'Technique tip', msg: 'Give me one technique tip — something I can work on in the next few minutes to sound better.' }, + { label: 'Why does this sound good?', msg: 'Looking at my recent chord history, why do these chords sound good together? What\'s the music theory behind it?' }, +] + +// ── Simple text renderer (bold + line breaks) ───────────────────────────────── +function MessageText({ text }) { + const lines = text.split('\n') + return ( +
+ {lines.map((line, i) => { + if (!line.trim()) return
+ // Bold: **text** + const parts = line.split(/(\*\*[^*]+\*\*)/) + return ( +

+ {parts.map((part, j) => + part.startsWith('**') && part.endsWith('**') + ? {part.slice(2, -2)} + : part + )} +

+ ) + })} +
+ ) +} + +// ── Main component ──────────────────────────────────────────────────────────── +export default function MusicTeacher({ keyInfo, currentChord, bpm, chordHistory }) { + const [open, setOpen] = useState(false) + const [apiKey, setApiKey] = useState(() => localStorage.getItem(LS_KEY) ?? '') + const [showKeyInput, setShowKeyInput] = useState(false) + const [messages, setMessages] = useState([]) // [{role, content}] + const [input, setInput] = useState('') + const [loading, setLoading] = useState(false) + const [streaming, setStreaming] = useState('') // partial response being streamed + const [error, setError] = useState(null) + + const scrollRef = useRef(null) + const inputRef = useRef(null) + const abortRef = useRef(null) + + // Always scroll to bottom on new content + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [messages, streaming]) + + // Focus input when panel opens + useEffect(() => { + if (open && apiKey && inputRef.current) { + setTimeout(() => inputRef.current?.focus(), 50) + } + }, [open, apiKey]) + + function saveKey(k) { + setApiKey(k) + localStorage.setItem(LS_KEY, k) + } + + function clearKey() { + setApiKey('') + localStorage.removeItem(LS_KEY) + setShowKeyInput(true) + } + + const sendMessage = useCallback(async (userText) => { + if (!userText.trim() || loading || !apiKey) return + + setError(null) + const userMsg = { role: 'user', content: userText.trim() } + const nextMessages = [...messages, userMsg] + setMessages(nextMessages) + setInput('') + setLoading(true) + setStreaming('') + + const context = { keyInfo, currentChord, bpm, chordHistory } + + try { + const ctrl = new AbortController() + abortRef.current = ctrl + + const res = await fetch(API_URL, { + method: 'POST', + signal: ctrl.signal, + headers: { + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: MODEL, + max_tokens: 1024, + stream: true, + system: buildSystemPrompt(context), + messages: nextMessages, + }), + }) + + if (!res.ok) { + const body = await res.json().catch(() => ({})) + throw new Error(body?.error?.message ?? `API error ${res.status}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + let full = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + const chunk = decoder.decode(value, { stream: true }) + for (const line of chunk.split('\n')) { + if (!line.startsWith('data: ')) continue + const data = line.slice(6).trim() + if (data === '[DONE]' || !data) continue + try { + const ev = JSON.parse(data) + if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') { + full += ev.delta.text + setStreaming(full) + } + } catch {} + } + } + + setMessages(prev => [...prev, { role: 'assistant', content: full }]) + setStreaming('') + } catch (err) { + if (err.name !== 'AbortError') { + setError(err.message) + } + } finally { + setLoading(false) + abortRef.current = null + } + }, [messages, loading, apiKey, keyInfo, currentChord, bpm, chordHistory]) + + function stopGeneration() { + abortRef.current?.abort() + if (streaming) { + setMessages(prev => [...prev, { role: 'assistant', content: streaming }]) + setStreaming('') + } + setLoading(false) + } + + function handleKeyDown(e) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + sendMessage(input) + } + } + + const hasKey = apiKey.trim().length > 0 + + // Dot: purple when API key set, gray otherwise + const dotClass = hasKey ? 'bg-accent' : 'bg-gray-700' + + return ( +
+ {/* ── Header ─────────────────────────────────────────────────────────── */} +
+ +
+ {/* Key indicator */} + + +
+
+ + {/* ── Body ───────────────────────────────────────────────────────────── */} + {open && ( +
+ + {/* API key input (shown when no key or user wants to change) */} + {(!hasKey || showKeyInput) && ( +
+

+ Enter your Anthropic API key to enable the music teacher. Stored locally on your device only. +

+
+ setApiKey(e.target.value)} + placeholder="sk-ant-..." + className="flex-1 px-2.5 py-1.5 bg-surface border border-border rounded-lg text-xs text-gray-300 focus:outline-none focus:border-accent font-mono" + /> + + {hasKey && ( + + )} +
+
+ )} + + {hasKey && ( + <> + {/* Live session context strip */} +
+ Now: + {keyInfo ? ( + {keyInfo.root} {keyInfo.mode} + ) : ( + no key + )} + · + {currentChord ? ( + {currentChord.name} + ) : ( + no chord + )} + · + {bpm ? `${Math.round(bpm)} bpm` : '— bpm'} + {messages.length > 0 && ( + + )} +
+ + {/* Chat messages */} + {messages.length > 0 || streaming ? ( +
+ {messages.map((m, i) => ( +
+ {m.role === 'user' ? ( +
+ {m.content} +
+ ) : ( +
+ +
+ )} +
+ ))} + {streaming && ( +
+ + +
+ )} + {error && ( +
+ {error} +
+ )} +
+ ) : ( + /* Quick-action chips (shown when no chat history yet) */ +
+

Ask something

+
+ {CHIPS.map(chip => ( + + ))} +
+
+ )} + + {/* Input row */} +
+