loop station
This commit is contained in:
@@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
||||
return ((bestMidi % 12) + 12) % 12
|
||||
}
|
||||
|
||||
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, audioDeviceId = null }) {
|
||||
export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, isListening, minClarity = 0.80, minVolume = 0.01, onPermissionError, audioDeviceId = null, onStreamReady = null }) {
|
||||
const audioCtxRef = useRef(null)
|
||||
const timeBufRef = useRef(null)
|
||||
const freqBufRef = useRef(null)
|
||||
@@ -96,6 +96,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
||||
const onOnsetRef = useRef(onOnset)
|
||||
const onWaveformRef = useRef(onWaveform)
|
||||
const onPermissionErrorRef = useRef(onPermissionError)
|
||||
const onStreamReadyRef = useRef(onStreamReady)
|
||||
const minClarityRef = useRef(minClarity)
|
||||
const minVolumeRef = useRef(minVolume)
|
||||
const smoothRmsRef = useRef(0)
|
||||
@@ -106,6 +107,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
||||
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
|
||||
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
|
||||
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
||||
useEffect(() => { onStreamReadyRef.current = onStreamReady }, [onStreamReady])
|
||||
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
||||
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
||||
|
||||
@@ -154,6 +156,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
||||
}
|
||||
streamRef.current = stream
|
||||
activeRef.current = true
|
||||
onStreamReadyRef.current?.(stream)
|
||||
smoothRmsRef.current = 0
|
||||
lastOnsetRef.current = 0
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import ChordBox from './ChordBox'
|
||||
import MiniPiano from './MiniPiano'
|
||||
import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings'
|
||||
import { CHORD_TYPES, NOTES, getChordsInKey, toRomanNumeral, getSuggestedProgressions } from '../lib/theory'
|
||||
import { FAMOUS_PROGRESSIONS, progressionInKey, parseChord as parseChordEd } from '../lib/education'
|
||||
import { FAMOUS_PROGRESSIONS, progressionInKey, getChordSubstitutions, CHORD_PLAYBOOK } from '../lib/education'
|
||||
|
||||
const CHORD_SUFFIX_OPTIONS = [
|
||||
{ key: 'maj', label: 'Major' },
|
||||
@@ -300,6 +300,324 @@ function ProgressionsSubTab({ chordName, onChordClick }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Theory tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
const CHORD_THEORY = {
|
||||
maj: {
|
||||
name: 'Major',
|
||||
formula: 'Root + Major 3rd (4 semitones) + Perfect 5th (7 semitones)',
|
||||
vibe: 'Bright, happy, resolved. The most "complete" sound in Western music.',
|
||||
beginner: 'Major chords are the foundation of almost every song you know. They feel stable and uplifting — like a musical full stop.',
|
||||
tension: 'Low — very stable',
|
||||
color: 'text-yellow-400',
|
||||
},
|
||||
min: {
|
||||
name: 'Minor',
|
||||
formula: 'Root + Minor 3rd (3 semitones) + Perfect 5th (7 semitones)',
|
||||
vibe: 'Dark, melancholic, introspective. The 3rd is lowered by just one semitone — that one note changes everything.',
|
||||
beginner: 'One note separates major from minor. Minor chords carry emotion and depth — sadness, mystery, tension.',
|
||||
tension: 'Low-medium — stable but moody',
|
||||
color: 'text-blue-400',
|
||||
},
|
||||
dom7: {
|
||||
name: 'Dominant 7th',
|
||||
formula: 'Major triad + Minor 7th (10 semitones)',
|
||||
vibe: 'Tense, bluesy, urgent. Wants desperately to resolve to a chord a 5th lower.',
|
||||
beginner: 'The 7th chord is the engine of blues and jazz. It creates tension that begs to resolve — like holding your breath. Play G7 then C to feel it.',
|
||||
tension: 'High — strongly pulls to resolution',
|
||||
color: 'text-red-400',
|
||||
},
|
||||
maj7: {
|
||||
name: 'Major 7th',
|
||||
formula: 'Major triad + Major 7th (11 semitones)',
|
||||
vibe: 'Dreamy, lush, sophisticated. Jazz-infused warmth without the tension of a dominant 7th.',
|
||||
beginner: 'The major 7th is the note just below the octave. Adding it to a major chord gives you that smooth jazz-bossa nova sound — think "Autumn Leaves".',
|
||||
tension: 'Very low — ethereal and floating',
|
||||
color: 'text-purple-400',
|
||||
},
|
||||
min7: {
|
||||
name: 'Minor 7th',
|
||||
formula: 'Minor triad + Minor 7th (10 semitones)',
|
||||
vibe: 'Smooth, soulful, relaxed. Darker than major 7th but less tense than a dominant 7th.',
|
||||
beginner: 'Minor 7ths are everywhere in soul, R&B, and jazz. They\'re minor chords with added warmth — moody but not harsh.',
|
||||
tension: 'Low-medium — smooth and flowing',
|
||||
color: 'text-indigo-400',
|
||||
},
|
||||
dim: {
|
||||
name: 'Diminished',
|
||||
formula: 'Root + Minor 3rd (3 semitones) + Diminished 5th (6 semitones)',
|
||||
vibe: 'Dark, tense, unstable. The flattened 5th creates a tritone interval — historically called "diabolus in musica" (the devil in music).',
|
||||
beginner: 'Diminished chords are passing chords — they create maximum tension so the next chord feels like a huge relief. Like a musical cliffhanger.',
|
||||
tension: 'Very high — wants to resolve immediately',
|
||||
color: 'text-orange-400',
|
||||
},
|
||||
dim7: {
|
||||
name: 'Diminished 7th',
|
||||
formula: 'Diminished triad + Diminished 7th (9 semitones) — fully symmetric, all minor 3rds',
|
||||
vibe: 'Extremely tense and dramatic. Used in horror film scores and dramatic classical passages.',
|
||||
beginner: 'All four notes are equally spaced (all minor 3rds apart), making it the most symmetrical and unstable chord. Classic "villain arrives" sound.',
|
||||
tension: 'Extreme — maximum instability',
|
||||
color: 'text-red-600',
|
||||
},
|
||||
half_dim: {
|
||||
name: 'Half-Diminished (m7♭5)',
|
||||
formula: 'Diminished triad + Minor 7th (10 semitones)',
|
||||
vibe: 'Dark and tense but with slightly more resolution than full dim7. The "ii" chord in minor ii–V–i jazz progressions.',
|
||||
beginner: 'Half-diminished sits between a minor 7th and a fully diminished chord. It\'s the moody jazz workhorse — think the intro to "Autumn Leaves".',
|
||||
tension: 'High — tense but musical',
|
||||
color: 'text-orange-500',
|
||||
},
|
||||
aug: {
|
||||
name: 'Augmented',
|
||||
formula: 'Root + Major 3rd (4 semitones) + Augmented 5th (8 semitones) — all major 3rds',
|
||||
vibe: 'Eerie, floating, dreamlike. The raised 5th creates instability that can resolve either up or down.',
|
||||
beginner: 'Augmented chords sound like something is about to happen. They\'re often used as a passing chord between major and minor — the 5th feels like it\'s "reaching" upward.',
|
||||
tension: 'High — ambiguous direction',
|
||||
color: 'text-emerald-400',
|
||||
},
|
||||
sus4: {
|
||||
name: 'Suspended 4th',
|
||||
formula: 'Root + Perfect 4th (5 semitones) + Perfect 5th (7 semitones)',
|
||||
vibe: 'Open, unresolved, expectant. The 3rd is replaced by a 4th — neither major nor minor, just floating.',
|
||||
beginner: '"Sus" means suspended — the 3rd is suspended in mid-air. It wants to drop down to a major or minor chord. Classic rock move: sus4 → major.',
|
||||
tension: 'Medium — pleasant tension, easy on the ear',
|
||||
color: 'text-cyan-400',
|
||||
},
|
||||
sus2: {
|
||||
name: 'Suspended 2nd',
|
||||
formula: 'Root + Major 2nd (2 semitones) + Perfect 5th (7 semitones)',
|
||||
vibe: 'Airy, spacious, ambiguous. Like sus4 but lighter — the 2nd sits high above the root.',
|
||||
beginner: 'Sus2 is a favourite of modern pop and ambient music. Without a 3rd, it has no major/minor quality — it just floats. Think Sting, U2, Coldplay.',
|
||||
tension: 'Low-medium — open and spacious',
|
||||
color: 'text-teal-400',
|
||||
},
|
||||
maj6: {
|
||||
name: 'Major 6th',
|
||||
formula: 'Major triad + Major 6th (9 semitones)',
|
||||
vibe: 'Sweet, vintage, nostalgic. The 6th adds a note from the scale without the tension of a 7th.',
|
||||
beginner: "The 6th is a colour tone that sweetens a major chord. Common in jazz, bossa nova, and 50s pop — \"Misty\" and \"Fly Me To The Moon\" territory.",
|
||||
tension: 'Very low — sweeter than major triad',
|
||||
color: 'text-amber-300',
|
||||
},
|
||||
min6: {
|
||||
name: 'Minor 6th',
|
||||
formula: 'Minor triad + Major 6th (9 semitones)',
|
||||
vibe: 'Bittersweet, exotic, dramatic. A major 6th over a minor chord creates a striking contrast.',
|
||||
beginner: 'Minor 6ths have a flamenco/tango feel. The bright 6th sitting on top of a dark minor chord creates a sophisticated tension — think Django Reinhardt.',
|
||||
tension: 'Medium — intriguing contrast',
|
||||
color: 'text-amber-400',
|
||||
},
|
||||
add9: {
|
||||
name: 'Add 9',
|
||||
formula: 'Major triad + Major 9th (14 semitones = octave + 2)',
|
||||
vibe: 'Open, modern, slightly epic. The 9th adds colour without the smoothness of a 7th.',
|
||||
beginner: 'Add9 is the chord of modern rock and pop. Unlike maj9 (which also has a 7th), add9 keeps things clean and direct. Coldplay, Radiohead, and U2 love it.',
|
||||
tension: 'Very low — bright and open',
|
||||
color: 'text-lime-400',
|
||||
},
|
||||
}
|
||||
|
||||
const INTERVAL_NAMES = {
|
||||
0: 'Root', 2: 'Major 2nd', 3: 'Minor 3rd', 4: 'Major 3rd',
|
||||
5: 'Perfect 4th', 6: 'Tritone (♭5)', 7: 'Perfect 5th',
|
||||
8: 'Aug 5th', 9: 'Major 6th', 10: 'Minor 7th', 11: 'Major 7th',
|
||||
14: 'Major 9th',
|
||||
}
|
||||
|
||||
function TheoryTab({ chordName }) {
|
||||
const parsed = parseChord(chordName)
|
||||
if (!parsed) return <p className="text-gray-500 text-sm text-center py-8">Could not parse chord.</p>
|
||||
|
||||
const { rootPc, type } = parsed
|
||||
const typeInfo = CHORD_TYPES[type]
|
||||
const theory = CHORD_THEORY[type]
|
||||
const subs = getChordSubstitutions(chordName)
|
||||
|
||||
// Actual note names
|
||||
const noteNames = (typeInfo?.intervals ?? []).map(iv => NOTES[(rootPc + iv) % 12])
|
||||
|
||||
// Roles this chord can play
|
||||
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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* ── What is this chord? ── */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<div className="flex items-baseline gap-3 mb-3">
|
||||
<span className={`text-lg font-black ${theory?.color ?? 'text-accent'}`}>{chordName}</span>
|
||||
<span className="text-sm text-gray-400">{theory?.name ?? type}</span>
|
||||
</div>
|
||||
{theory && (
|
||||
<>
|
||||
<p className="text-sm text-gray-200 leading-relaxed mb-2">{theory.beginner}</p>
|
||||
<p className="text-xs text-gray-500 italic leading-relaxed">{theory.vibe}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Notes & Formula ── */}
|
||||
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">Notes in this chord</p>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{(typeInfo?.intervals ?? []).map((iv, i) => (
|
||||
<div key={i} className={`flex flex-col items-center px-3 py-2 rounded-xl border ${
|
||||
i === 0 ? 'bg-accent/20 border-accent text-accent' : 'bg-panel border-border text-gray-300'
|
||||
}`}>
|
||||
<span className="text-base font-black">{noteNames[i]}</span>
|
||||
<span className="text-[10px] text-gray-500 leading-none mt-0.5">{INTERVAL_NAMES[iv] ?? `+${iv}`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{theory && (
|
||||
<div className="text-xs text-gray-600 font-mono bg-panel/50 rounded-lg px-3 py-2 border border-border">
|
||||
{theory.formula}
|
||||
</div>
|
||||
)}
|
||||
{theory && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className="text-[10px] uppercase tracking-wider text-gray-600">Tension:</span>
|
||||
<span className="text-xs text-gray-400">{theory.tension}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Chord substitutions ── */}
|
||||
{subs.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">Colour swaps — try these instead</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
{subs.map((sub, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||
<span className="text-sm font-black text-accent shrink-0 w-16">{sub.chord}</span>
|
||||
<p className="text-xs text-gray-400 leading-snug">{sub.tip}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Keys this chord belongs to ── */}
|
||||
{ROLES.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">{chordName} appears in these keys</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROLES.slice(0, 10).map(({ keyRoot, mode, rn }) => (
|
||||
<div key={`${keyRoot}-${mode}`}
|
||||
className="px-3 py-2 bg-surface border border-border rounded-xl text-xs flex items-center gap-2">
|
||||
<span className="font-bold text-white">{keyRoot}</span>
|
||||
<span className="text-gray-500 capitalize">{mode}</span>
|
||||
<span className="text-amber-400 font-bold">{rn}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-700 mt-2">
|
||||
Roman numerals show the chord's role: I/i = home, IV = subdominant, V = dominant tension, vi/♭VI = relative minor/major, etc.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Learn tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function LearnTab({ chordName }) {
|
||||
const parsed = parseChord(chordName)
|
||||
const playbook = parsed ? CHORD_PLAYBOOK[parsed.type] : null
|
||||
|
||||
if (!playbook) {
|
||||
return <p className="text-gray-500 text-sm text-center py-8">No jam content for {chordName} yet.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
|
||||
{/* ── Jam role ── */}
|
||||
<div className="px-4 py-3 bg-accent/10 border border-accent/20 rounded-xl">
|
||||
<p className="text-[10px] uppercase tracking-wider text-accent/60 mb-1">Your role in the jam</p>
|
||||
<p className="text-sm text-white leading-relaxed">{playbook.jamRole}</p>
|
||||
</div>
|
||||
|
||||
{/* ── Voicings for jamming ── */}
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Voicings — when to use which</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{playbook.voicings.map((v, i) => (
|
||||
<div key={i} className="flex gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/20 transition-colors">
|
||||
<span className="text-accent font-black text-lg shrink-0 leading-none mt-0.5">{i + 1}</span>
|
||||
<div>
|
||||
<p className="text-xs font-bold text-white mb-0.5">{v.name}</p>
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{v.use}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-700 mt-2">See the Guitar tab for the actual fingerings of each shape.</p>
|
||||
</div>
|
||||
|
||||
{/* ── Licks & fills ── */}
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Licks & fills</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{playbook.licks.map((l, i) => (
|
||||
<div key={i} className="p-4 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<p className="text-sm font-bold text-white">{l.title}</p>
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-accent/10 border border-accent/20 text-accent">{l.style}</span>
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono text-accent/70 bg-black/40 border border-border rounded-lg px-3 py-2 overflow-x-auto leading-relaxed whitespace-pre mb-2">{l.tab}</pre>
|
||||
<p className="text-xs text-amber-400/80">
|
||||
<span className="font-semibold text-amber-400">Key insight: </span>{l.tip}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Jam tips ── */}
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Jam tips</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{playbook.jamTips.map((tip, i) => (
|
||||
<div key={i} className="flex gap-2.5 text-xs text-gray-300 leading-relaxed p-2.5 rounded-lg bg-surface border border-border">
|
||||
<span className="text-accent shrink-0 font-bold mt-0.5">→</span>
|
||||
<p>{tip}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Loop station practice ── */}
|
||||
{playbook.loopPractice?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Loop station practice</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{playbook.loopPractice.map((lp, i) => (
|
||||
<div key={i} className="p-3 bg-surface border border-border rounded-xl border-l-2 border-l-accent/40">
|
||||
<p className="text-xs font-bold text-white mb-1">🔁 {lp.title}</p>
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{lp.body}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Explore tab ──────────────────────────────────────────────────────────────
|
||||
function ExploreTab({ initialChord, keyInfo, chordHistory }) {
|
||||
const parsed = parseChord(initialChord)
|
||||
@@ -372,9 +690,8 @@ function ExploreTab({ initialChord, keyInfo, chordHistory }) {
|
||||
{/* ── Sub-tabs ── */}
|
||||
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
|
||||
{[
|
||||
{ key: 'guitar', label: '🎸 Guitar' },
|
||||
{ key: 'piano', label: '🎹 Piano' },
|
||||
{ key: 'progressions', label: '🎵 Progressions' },
|
||||
{ key: 'guitar', label: '🎸 Guitar' },
|
||||
{ key: 'piano', label: '🎹 Piano' },
|
||||
].map(t => (
|
||||
<button key={t.key}
|
||||
onClick={() => setSubTab(t.key)}
|
||||
@@ -386,16 +703,15 @@ function ExploreTab({ initialChord, keyInfo, chordHistory }) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{subTab === 'guitar' && <GuitarTab chordName={chordName} />}
|
||||
{subTab === 'piano' && <PianoTab chordName={chordName} />}
|
||||
{subTab === 'progressions' && <ProgressionsSubTab chordName={chordName} onChordClick={c => selectChord(c)} />}
|
||||
{subTab === 'guitar' && <GuitarTab chordName={chordName} />}
|
||||
{subTab === 'piano' && <PianoTab chordName={chordName} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 px-6 pt-4">
|
||||
<div className="flex gap-1 px-6 pt-4 overflow-x-auto">
|
||||
{[
|
||||
{ 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 => (
|
||||
<button key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className={`px-4 py-2 rounded-t-xl text-sm font-semibold transition-all border-b-2 ${
|
||||
className={`px-4 py-2 rounded-t-xl text-sm font-semibold transition-all border-b-2 whitespace-nowrap ${
|
||||
tab === t.key
|
||||
? 'text-accent border-accent bg-accent/10'
|
||||
: 'text-gray-500 border-transparent hover:text-gray-300'
|
||||
@@ -456,9 +775,12 @@ export default function ChordDetailModal({ chord, onClose, keyInfo, chordHistory
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-6 py-5">
|
||||
{tab === 'guitar' && <GuitarTab chordName={chord} />}
|
||||
{tab === 'piano' && <PianoTab chordName={chord} />}
|
||||
{tab === 'explore' && <ExploreTab initialChord={chord} keyInfo={keyInfo} chordHistory={chordHistory} />}
|
||||
{tab === 'guitar' && <GuitarTab chordName={chord} />}
|
||||
{tab === 'piano' && <PianoTab chordName={chord} />}
|
||||
{tab === 'theory' && <TheoryTab chordName={chord} />}
|
||||
{tab === 'learn' && <LearnTab chordName={chord} />}
|
||||
{tab === 'progressions' && <ProgressionsSubTab chordName={chord} onChordClick={c => { onChordClick?.(c) }} />}
|
||||
{tab === 'explore' && <ExploreTab initialChord={chord} keyInfo={keyInfo} chordHistory={chordHistory} />}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex flex-col items-center gap-1 select-none relative">
|
||||
|
||||
{/* Delete confirmation overlay (long-press) */}
|
||||
{deleting && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-xl bg-black/90 border border-red-500">
|
||||
<button
|
||||
className="text-[10px] font-bold text-red-400 px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-900"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
className="text-[10px] text-gray-500 hover:text-gray-300"
|
||||
onClick={() => setDeleting(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick clear button — visible on non-empty slots */}
|
||||
{status !== 'empty' && !deleting && (
|
||||
<button
|
||||
className="absolute -top-1.5 -right-1.5 z-10 w-4 h-4 rounded-full bg-gray-800 border border-border text-gray-500 hover:bg-red-900/60 hover:text-red-400 hover:border-red-700 text-[9px] leading-none flex items-center justify-center transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(slotIdx) }}
|
||||
title="Clear slot"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Main button */}
|
||||
<button
|
||||
className={`relative w-[76px] h-[54px] rounded-xl border-2 overflow-hidden flex flex-col items-center justify-center gap-0.5 transition-colors cursor-pointer ${style.bg} ${style.border} ${status === 'recording' ? 'animate-pulse' : ''}`}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerLeave}
|
||||
>
|
||||
<span className={`text-lg leading-none ${style.iconColor}`}>
|
||||
{style.icon}
|
||||
</span>
|
||||
<span className={`text-[9px] font-bold uppercase tracking-widest leading-none ${style.iconColor} opacity-70`}>
|
||||
{status === 'recording'
|
||||
? `${(recordingDuration ?? 0).toFixed(1)}s`
|
||||
: `Loop ${slotIdx + 1}`}
|
||||
</span>
|
||||
|
||||
{/* Progress bar */}
|
||||
{isActive && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-border">
|
||||
<div
|
||||
ref={progressRef}
|
||||
className="h-full bg-accent"
|
||||
style={{ width: '0%', transition: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Controls row (playing/muted only) */}
|
||||
{isActive && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Volume toggle */}
|
||||
<button
|
||||
className={`text-[11px] transition-colors ${showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-400'}`}
|
||||
onClick={() => setShowVol(v => !v)}
|
||||
title="Volume"
|
||||
>
|
||||
🔊
|
||||
</button>
|
||||
{showVol && (
|
||||
<input
|
||||
type="range" min={0} max={1} step={0.05}
|
||||
value={volume}
|
||||
onChange={e => 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 && (
|
||||
<button
|
||||
className="text-[11px] text-gray-600 hover:text-amber-400 transition-colors"
|
||||
onClick={() => onRetrim(slotIdx)}
|
||||
title="Re-trim this loop"
|
||||
>
|
||||
✂
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status label */}
|
||||
<span className={`text-[9px] uppercase tracking-wider leading-none ${style.iconColor} opacity-50`}>
|
||||
{LABEL[status] ?? ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="relative mx-4 mb-3 rounded overflow-hidden bg-surface border border-border"
|
||||
style={{ height: `${H_MASTER}px` }}>
|
||||
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||
{!masterLen && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="text-[10px] text-gray-700">
|
||||
record first loop to set master length
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{masterLen && (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute top-0 bottom-0 w-px bg-white/50 pointer-events-none"
|
||||
style={{ left: '0%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className={`flex items-stretch border rounded-lg mb-1.5 overflow-hidden transition-colors ${borderClass}`}>
|
||||
|
||||
{/* Left: tap button (state dot + track number) */}
|
||||
<button
|
||||
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||
disabled={!isClickable}
|
||||
title={
|
||||
slot.status === 'empty' ? 'Tap to record' :
|
||||
slot.status === 'recording' ? 'Tap to stop' :
|
||||
slot.status === 'playing' ? 'Tap to mute' :
|
||||
slot.status === 'muted' ? 'Tap to unmute' : ''
|
||||
}
|
||||
className="flex flex-col items-center justify-center gap-1 px-3 bg-surface border-r border-border shrink-0 hover:bg-white/5 transition-colors disabled:cursor-default"
|
||||
style={{ width: '44px' }}
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||
<span className="text-[10px] text-gray-600 font-mono">{slotIdx + 1}</span>
|
||||
</button>
|
||||
|
||||
{/* Canvas: waveform / recording / empty */}
|
||||
<div
|
||||
className="relative flex-1 cursor-pointer"
|
||||
style={{ height: `${H_TRACK}px` }}
|
||||
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||
|
||||
{slot.status === 'empty' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<span className="text-xs text-gray-700">tap to record</span>
|
||||
</div>
|
||||
)}
|
||||
{slot.status === 'trimming' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-amber-950/20 pointer-events-none">
|
||||
<span className="text-xs text-amber-500/60">trimming ↓</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Moving playhead */}
|
||||
{isActive && (
|
||||
<div
|
||||
ref={playheadRef}
|
||||
className="absolute top-0 bottom-0 w-px bg-white/40 pointer-events-none"
|
||||
style={{ left: '-2px' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: controls */}
|
||||
<div className="flex items-center gap-1 px-2 bg-surface border-l border-border shrink-0">
|
||||
{showVol && (
|
||||
<input
|
||||
type="range"
|
||||
min={0} max={1} step={0.01}
|
||||
value={slot.volume}
|
||||
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
|
||||
className="w-14 accent-purple-500"
|
||||
title="Volume"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowVol(v => !v)}
|
||||
className={`w-7 h-7 flex items-center justify-center rounded text-base transition-colors ${
|
||||
showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-300'
|
||||
}`}
|
||||
title="Volume"
|
||||
>
|
||||
{slot.status === 'muted' ? '🔇' : '🔊'}
|
||||
</button>
|
||||
{isActive && slot.originalBuffer && (
|
||||
<button
|
||||
onClick={() => onRetrim(slotIdx)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-gray-600 hover:text-amber-400 transition-colors text-sm"
|
||||
title="Re-trim loop"
|
||||
>
|
||||
✂
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onDelete(slotIdx)}
|
||||
className="w-7 h-7 flex items-center justify-center rounded text-gray-700 hover:text-red-400 transition-colors text-sm"
|
||||
title="Clear track"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||
<span>LOOP STATION</span>
|
||||
{masterLabel && (
|
||||
<span className="text-[11px] text-gray-600 font-mono">{masterLabel}</span>
|
||||
)}
|
||||
{recordingCount > 0 && (
|
||||
<span className="text-[11px] text-red-400">● rec</span>
|
||||
)}
|
||||
{playingCount > 0 && recordingCount === 0 && (
|
||||
<span className="text-[11px] text-accent">
|
||||
{playingCount} loop{playingCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 bg-surface border border-border rounded-lg px-2 py-1">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider">BPM</span>
|
||||
<input
|
||||
type="number"
|
||||
min={40} max={300} step={1}
|
||||
value={gridBpm}
|
||||
onChange={e => 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' }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||
title={open ? 'Collapse' : 'Expand'}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
{open
|
||||
? <polyline points="2,8 6,4 10,8" />
|
||||
: <polyline points="2,4 6,8 10,4" />
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ────────────────────────────────────────────────────────────── */}
|
||||
{open && (
|
||||
<div className="border-t border-border pt-3">
|
||||
|
||||
{/* Master timeline */}
|
||||
<MasterTimeline
|
||||
masterStartRef={masterStartRef}
|
||||
masterLenRef={masterLenRef}
|
||||
audioCtxRef={audioCtxRef}
|
||||
bpm={gridBpmNum}
|
||||
masterLen={masterLen}
|
||||
/>
|
||||
|
||||
{/* Track rows */}
|
||||
<div className="px-4">
|
||||
{slots.map((slot, i) => (
|
||||
<TrackRow
|
||||
key={i}
|
||||
slot={slot}
|
||||
slotIdx={i}
|
||||
bpm={gridBpmNum}
|
||||
audioCtxRef={audioCtxRef}
|
||||
masterStartRef={masterStartRef}
|
||||
masterLenRef={masterLenRef}
|
||||
onSlotClick={onSlotClick}
|
||||
onRetrim={onRetrim}
|
||||
onDelete={onDelete}
|
||||
onVolumeChange={onVolumeChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add track */}
|
||||
<div className="px-4 pb-3">
|
||||
<button
|
||||
onClick={onAddSlot}
|
||||
className="w-full py-1.5 rounded-lg border border-dashed border-border text-gray-700 hover:border-accent/40 hover:text-accent/60 transition-colors text-xs flex items-center justify-center gap-2"
|
||||
>
|
||||
<span className="text-base leading-none">+</span>
|
||||
<span>Add Track</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LoopTrimmer — shown below tracks when trimming */}
|
||||
{trimmingIdx !== -1 && (
|
||||
<LoopTrimmer
|
||||
slot={slots[trimmingIdx]}
|
||||
slotIdx={trimmingIdx}
|
||||
bpm={gridBpmNum}
|
||||
audioCtxRef={audioCtxRef}
|
||||
onCommit={onCommitTrim}
|
||||
onCancel={onCancelRecord}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="border-t border-border">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between px-4 pt-3 pb-1">
|
||||
<span className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
Trim — Loop {slotIdx + 1}
|
||||
</span>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 font-mono">
|
||||
<span className="text-gray-600">{fmtMs(startSec)} → {fmtMs(endSec)}</span>
|
||||
<span className="text-accent font-semibold">{fmtSec(selectedSec)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Waveform + handles */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mx-4 h-20 rounded-lg overflow-visible cursor-crosshair select-none"
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full h-full block rounded-lg" />
|
||||
|
||||
{/* Left handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||
style={{ left: `${trimStart * 100}%` }}
|
||||
onMouseDown={handleMouseDown('start')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||
{fmtMs(startSec)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right handle */}
|
||||
<div
|
||||
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||
style={{ left: `${trimEnd * 100}%` }}
|
||||
onMouseDown={handleMouseDown('end')}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||
{fmtMs(endSec)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar: preview + bar snap + hint */}
|
||||
<div className="flex items-center gap-2 px-4 pt-2 pb-1 flex-wrap">
|
||||
{/* Preview play/stop */}
|
||||
<button
|
||||
onClick={togglePreview}
|
||||
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg border text-xs font-medium transition-all ${
|
||||
previewing
|
||||
? 'bg-accent/20 border-accent text-accent'
|
||||
: 'bg-surface border-border text-gray-400 hover:text-white hover:border-gray-500'
|
||||
}`}
|
||||
title="Preview loop selection"
|
||||
>
|
||||
{previewing
|
||||
? <><span>⏹</span><span>Stop</span></>
|
||||
: <><span>▶</span><span>Preview</span></>
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Bar snap buttons — only if BPM is set */}
|
||||
{barSec && duration && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<span className="text-[10px] text-gray-600 uppercase tracking-wider mr-0.5">snap end →</span>
|
||||
{[1, 2, 4].map(n => {
|
||||
const endFrac = trimStart + (n * barSec) / duration
|
||||
const fits = endFrac <= 1.02
|
||||
return (
|
||||
<button
|
||||
key={n}
|
||||
onClick={() => snapBars(n)}
|
||||
disabled={!fits}
|
||||
className="px-2 py-0.5 rounded border border-border text-[10px] text-gray-400 hover:text-accent hover:border-accent/50 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title={`Set end to ${n} bar${n > 1 ? 's' : ''} from start`}
|
||||
>
|
||||
{n} bar{n > 1 ? 's' : ''}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="ml-auto text-[10px] text-gray-700">
|
||||
{bpm ? `${bpm} BPM grid` : 'no BPM — trim freely'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2 px-4 pb-3">
|
||||
<button
|
||||
onClick={() => { stopPreview(); onCommit(slotIdx, trimStart, trimEnd) }}
|
||||
className="px-5 py-1.5 bg-accent text-white text-sm font-bold rounded-lg hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
Set Loop ▶
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { stopPreview(); onCommit(slotIdx, 0, 1) }}
|
||||
className="px-4 py-1.5 bg-surface border border-border text-gray-400 text-sm rounded-lg hover:text-white hover:border-gray-500 transition-colors"
|
||||
title="Use the full recording without trimming"
|
||||
>
|
||||
Use Full
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { stopPreview(); onCancel(slotIdx) }}
|
||||
className="px-4 py-1.5 bg-surface border border-border text-gray-500 text-sm rounded-lg hover:text-red-400 hover:border-red-800 transition-colors ml-auto"
|
||||
>
|
||||
Re-record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-1">
|
||||
{lines.map((line, i) => {
|
||||
if (!line.trim()) return <div key={i} className="h-1" />
|
||||
// Bold: **text**
|
||||
const parts = line.split(/(\*\*[^*]+\*\*)/)
|
||||
return (
|
||||
<p key={i} className="leading-relaxed">
|
||||
{parts.map((part, j) =>
|
||||
part.startsWith('**') && part.endsWith('**')
|
||||
? <strong key={j} className="text-white font-semibold">{part.slice(2, -2)}</strong>
|
||||
: part
|
||||
)}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||
<span>MUSIC TEACHER</span>
|
||||
<span className="text-[11px] text-gray-600">AI · Claude</span>
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Key indicator */}
|
||||
<button
|
||||
onClick={() => setShowKeyInput(v => !v)}
|
||||
className="text-[10px] text-gray-600 hover:text-gray-400 transition-colors px-1.5 py-0.5 rounded border border-transparent hover:border-border"
|
||||
title={hasKey ? 'API key set — click to change' : 'Set API key'}
|
||||
>
|
||||
{hasKey ? '🔑 key set' : '🔑 add key'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
{open
|
||||
? <polyline points="2,8 6,4 10,8" />
|
||||
: <polyline points="2,4 6,8 10,4" />
|
||||
}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Body ───────────────────────────────────────────────────────────── */}
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
|
||||
{/* API key input (shown when no key or user wants to change) */}
|
||||
{(!hasKey || showKeyInput) && (
|
||||
<div className="px-4 py-3 bg-surface/50 border-b border-border">
|
||||
<p className="text-xs text-gray-500 mb-2">
|
||||
Enter your <a className="text-accent underline" href="https://console.anthropic.com/keys" target="_blank" rel="noreferrer">Anthropic API key</a> to enable the music teacher. Stored locally on your device only.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={e => 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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { saveKey(apiKey); setShowKeyInput(false) }}
|
||||
disabled={!apiKey.trim()}
|
||||
className="px-3 py-1.5 bg-accent text-white text-xs font-bold rounded-lg hover:bg-accent/80 transition-colors disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
{hasKey && (
|
||||
<button
|
||||
onClick={() => setShowKeyInput(false)}
|
||||
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasKey && (
|
||||
<>
|
||||
{/* Live session context strip */}
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-[10px] font-mono">
|
||||
<span className="text-gray-600 uppercase tracking-wider">Now:</span>
|
||||
{keyInfo ? (
|
||||
<span className="text-accent">{keyInfo.root} {keyInfo.mode}</span>
|
||||
) : (
|
||||
<span className="text-gray-700">no key</span>
|
||||
)}
|
||||
<span className="text-gray-800">·</span>
|
||||
{currentChord ? (
|
||||
<span className="text-white">{currentChord.name}</span>
|
||||
) : (
|
||||
<span className="text-gray-700">no chord</span>
|
||||
)}
|
||||
<span className="text-gray-800">·</span>
|
||||
<span className="text-gray-500">{bpm ? `${Math.round(bpm)} bpm` : '— bpm'}</span>
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
onClick={() => { setMessages([]); setError(null) }}
|
||||
className="ml-auto text-gray-700 hover:text-gray-400 transition-colors"
|
||||
title="Clear conversation"
|
||||
>
|
||||
clear chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Chat messages */}
|
||||
{messages.length > 0 || streaming ? (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="max-h-72 overflow-y-auto px-4 py-3 space-y-3 text-xs"
|
||||
>
|
||||
{messages.map((m, i) => (
|
||||
<div key={i} className={m.role === 'user' ? 'flex justify-end' : ''}>
|
||||
{m.role === 'user' ? (
|
||||
<div className="max-w-[80%] bg-accent/20 border border-accent/30 rounded-xl rounded-tr-sm px-3 py-2 text-gray-200">
|
||||
{m.content}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-300 leading-relaxed">
|
||||
<MessageText text={m.content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{streaming && (
|
||||
<div className="text-gray-300 text-xs leading-relaxed">
|
||||
<MessageText text={streaming} />
|
||||
<span className="inline-block w-1.5 h-3.5 bg-accent/70 animate-pulse ml-0.5 align-middle" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-red-400 text-xs bg-red-950/30 border border-red-900/50 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Quick-action chips (shown when no chat history yet) */
|
||||
<div className="px-4 py-3">
|
||||
<p className="text-[10px] text-gray-700 mb-2 uppercase tracking-wider">Ask something</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{CHIPS.map(chip => (
|
||||
<button
|
||||
key={chip.label}
|
||||
onClick={() => sendMessage(chip.msg)}
|
||||
className="px-2.5 py-1 bg-surface border border-border rounded-full text-[10px] text-gray-400 hover:text-white hover:border-accent/50 hover:bg-accent/10 transition-all"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input row */}
|
||||
<div className="px-4 py-3 border-t border-border flex gap-2 items-end">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything about music theory, technique, or what to play…"
|
||||
rows={1}
|
||||
className="flex-1 px-3 py-2 bg-surface border border-border rounded-xl text-xs text-gray-300 placeholder-gray-700 focus:outline-none focus:border-accent resize-none leading-relaxed"
|
||||
style={{ maxHeight: '80px', overflowY: 'auto' }}
|
||||
/>
|
||||
{loading ? (
|
||||
<button
|
||||
onClick={stopGeneration}
|
||||
className="px-3 py-2 bg-surface border border-border text-gray-500 hover:text-red-400 hover:border-red-800 text-xs rounded-xl transition-colors shrink-0"
|
||||
title="Stop"
|
||||
>
|
||||
⏹
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim()}
|
||||
className="px-3 py-2 bg-accent text-white text-xs font-bold rounded-xl hover:bg-accent/80 transition-colors disabled:opacity-40 shrink-0"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick chips after first message */}
|
||||
{messages.length > 0 && (
|
||||
<div className="px-4 pb-3 flex flex-wrap gap-1.5">
|
||||
{CHIPS.slice(0, 4).map(chip => (
|
||||
<button
|
||||
key={chip.label}
|
||||
onClick={() => sendMessage(chip.msg)}
|
||||
disabled={loading}
|
||||
className="px-2 py-0.5 bg-surface border border-border rounded-full text-[9px] text-gray-600 hover:text-gray-300 hover:border-accent/30 transition-all disabled:opacity-30"
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user