loop station

This commit is contained in:
vadimwit
2026-03-20 01:03:43 +00:00
parent 8cd9b01941
commit f583979b3e
9 changed files with 2600 additions and 25 deletions
+38 -8
View File
@@ -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 ── */}
<ChordDetailModal chord={selectedChord} onClose={() => setSelectedChord(null)} keyInfo={effectiveKey} chordHistory={chordHistory} />
<ChordDetailModal chord={selectedChord} onClose={() => setSelectedChord(null)} onChordClick={setSelectedChord} keyInfo={effectiveKey} chordHistory={chordHistory} />
{/* ── Progression banner ── */}
<ProgressionBanner
@@ -561,12 +580,6 @@ export default function App() {
</div>
</div>
{/* ── Explore any chord — collapsible ── */}
<ExplorePanel
keyInfo={effectiveKey}
chordHistory={chordHistory}
onChordClick={setSelectedChord}
/>
{/* ── Current jam — collapsible ── */}
<CurrentJamPanel
@@ -576,6 +589,23 @@ export default function App() {
onChordClick={setSelectedChord}
/>
{/* ── Loop station ── */}
<LoopStation
slots={slots}
bpm={bpm}
masterLen={masterLen}
audioCtxRef={loopAudioCtxRef}
masterStartRef={loopMasterStartRef}
masterLenRef={loopMasterLenRef}
onSlotClick={handleSlotClick}
onCommitTrim={commitTrim}
onCancelRecord={cancelRecord}
onRetrim={retrimSlot}
onDelete={deleteSlot}
onVolumeChange={loopSetVolume}
onAddSlot={loopAddSlot}
/>
{/* ── Behind the scenes — collapsible ── */}
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
<button
+4 -1
View File
@@ -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
+331 -9
View File
@@ -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 iiVi 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 &amp; 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)
@@ -374,7 +692,6 @@ function ExploreTab({ initialChord, keyInfo, chordHistory }) {
{[
{ key: 'guitar', label: '🎸 Guitar' },
{ key: 'piano', label: '🎹 Piano' },
{ key: 'progressions', label: '🎵 Progressions' },
].map(t => (
<button key={t.key}
onClick={() => setSubTab(t.key)}
@@ -388,14 +705,13 @@ function ExploreTab({ initialChord, keyInfo, chordHistory }) {
{subTab === 'guitar' && <GuitarTab chordName={chordName} />}
{subTab === 'piano' && <PianoTab chordName={chordName} />}
{subTab === 'progressions' && <ProgressionsSubTab chordName={chordName} onChordClick={c => selectChord(c)} />}
</div>
)
}
// ─── Main modal ───────────────────────────────────────────────────────────────
export default function ChordDetailModal({ chord, onClose, keyInfo, chordHistory }) {
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'
@@ -458,6 +777,9 @@ export default function ChordDetailModal({ chord, onClose, keyInfo, chordHistory
<div className="px-6 py-5">
{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>
+169
View File
@@ -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>
)
}
+508
View File
@@ -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>
)
}
+337
View File
@@ -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>
)
}
+410
View File
@@ -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>
)
}
+437
View File
@@ -434,3 +434,440 @@ export function getChordSubstitutions(chordName) {
chord: NOTES[p.rootPc] + (CHORD_TYPES[sub.type]?.suffix ?? ''),
}))
}
// ─── Chord Playbook ───────────────────────────────────────────────────────────
// Jam-focused knowledge base per chord type.
// jamRole — one sentence on this chord's role in a live jam
// voicings — 2-3 voicings with their jam use-case (not fingering lessons)
// licks — 2-3 fills/licks with ASCII tab, style-labelled
// jamTips — 3 tight bullet points for live playing
// loopPractice — 1-2 concrete loop station exercises
export const CHORD_PLAYBOOK = {
maj: {
jamRole: 'The home chord in a major key — everything resolves here. Your job is to lock in the groove and hold the harmonic centre while others explore around you.',
voicings: [
{ name: 'Open position', use: 'Full, resonant. Use when you\'re the only chordal instrument — fills the room. Avoid it in a dense band (too much low-end clash with bass).' },
{ name: 'Barre mid-neck (E or A shape)', use: 'Controlled and punchy. Use in a band — you\'re sitting above the bassist\'s register. Good for rock and funk comping.' },
{ name: 'Partial 4-string (strings 14, upper neck)', use: 'Jazz and funk comping. Stays completely out of the low end. Ideal when there\'s a keys player — you add texture without competing.' },
],
licks: [
{
title: 'Major pentatonic walkup',
style: 'Rock / Country',
tab: 'e|-------------------------------5-7-|\nB|---------------------5-7-8---------|\nG|-----------4-5-7-------------------|\nD|-----4-5-7-------------------------|\nA|-5-7-------------------------------|\n (G major pentatonic — position shift for any key)',
tip: 'End on the root or the 3rd for a resolved feel. Use this as a fill between chord changes.',
},
{
title: 'Chord tone arpeggio fill',
style: 'Jazz / Funk',
tab: 'e|-----5-----------|\nB|---5---8---------|\nG|-5-------7-5-----|\nD|-----------------|\n (C major: R35high root)',
tip: 'Highlight the 3rd and 5th — these define the chord. Avoid the root on a strong beat to keep it moving.',
},
],
jamTips: [
'If a bassist is locking down the root, move your voicing up the neck — let them own the low end.',
'Behind a soloist: comp short stabs on beats 2 and 4. Full strums on every beat will drown them out.',
'Pedal point trick: hold one string on the root and let the chord move underneath — creates harmonic motion without a chord change.',
],
loopPractice: [
{ title: 'Comp loop + fill swap', body: 'Record a 2-bar major chord comp (stabs on beats 2 and 4). Loop it. Improvise fills over the top using the major pentatonic — aim to respond to the rhythm, not just run scales. Swap between "comping mode" and "fill mode" every 4 bars.' },
],
},
min: {
jamRole: 'Sets the emotional temperature of the jam. Minor chords invite exploration — soloists will look to you for the harmonic anchor. Hold it firmly and let the mood develop.',
voicings: [
{ name: 'Open minor (Em, Am, Dm)', use: 'Resonant and sustaining. Use when the jam is sparse or acoustic — let it ring while others move around it.' },
{ name: 'Barre mid-neck', use: 'Rock and pop jams. Punchy and controlled. Roll slightly onto the index bony edge for clean barres. Palm-mute lightly for a tighter comp.' },
{ name: 'Partial upper voicing (4 strings)', use: 'Jazz-funk context. Stays completely out of the bass. Add light muting for a percussive comp that doesn\'t muddy the mix.' },
],
licks: [
{
title: 'Minor pentatonic box 1',
style: 'Blues / Rock',
tab: 'e|--8-5-----------|\nB|------8-5-6-5---|\nG|------------7-5-|\nD|---------7------|\nA|----------------|\n (A minor pentatonic at position 5)',
tip: 'The b3 and b7 are your strongest notes over any minor chord. End phrases on one of them.',
},
{
title: 'Dorian groove phrase',
style: 'Jazz / Funk',
tab: 'e|-------------------|\nB|-------------------|\nG|--2-4-2-4-5-4-2----|\nD|--2-4-2-4---4-2----|\nA|--0----------------|\n (A Dorian — note the F# = natural 6th)',
tip: 'The natural 6th (F# in Am Dorian) is what gives Santana, Stevie Wonder, and jazz-funk their brightness over a minor chord.',
},
],
jamTips: [
'The minor pentatonic (5 notes) is the safe zone — you can\'t go wrong with it over any minor chord.',
'On a long minor vamp, try the Dorian mode (raise the 6th by one semitone) — it shifts the feel from melancholic to funky.',
'When the jam sits on a minor chord, play fewer chord tones and more single-note fills in the gaps — let the sustain breathe.',
],
loopPractice: [
{ title: 'Minor vamp + Dorian exploration', body: 'Record a 4-bar minor chord groove with gaps (e.g. hit on beat 1, let it ring, stab again on beat 3). Loop it. Solo over it with minor pentatonic for 2 laps. Then raise the 6th by one semitone (into Dorian) on the next 2 laps. Hear the shift from dark to groove.' },
],
},
dom7: {
jamRole: 'THE blues chord — in a 12-bar blues jam, every chord is a dom7. In jazz, it\'s the maximum-tension V chord. Either way: play with attitude and resolve with purpose.',
voicings: [
{ name: 'Full dom7 barre', use: 'Blues rhythm guitar. Combine with the shuffle pattern — bass note then top strings alternating. This IS the sound of a blues jam.' },
{ name: 'Shell voicing (root + 3rd + b7, 3 strings only)', use: 'Jazz comping. Three notes, right in the pocket. Leaves room for piano, sax, everything — the most polite voicing you can play.' },
{ name: '9th voicing (add the 2nd on top)', use: 'R&B and blues-rock upgrade. One extra note transforms the barre into something sophisticated without changing the function.' },
],
licks: [
{
title: 'Blues shuffle figure',
style: 'Blues',
tab: 'e|--------------------|\nB|--------------------|\nG|--------------------|\nD|--2-4--2-4----------|\nA|--2-4--2-4----------|\nE|--0----0------------|\n (E7 shuffle — repeat; move to A string for A7)',
tip: 'The shuffle feel is long-short, long-short (triplet with the middle note removed). Lock this in before speed.',
},
{
title: 'Mixolydian fill',
style: 'Jazz / Rock',
tab: 'e|--10-8---8-10-8--7---------|\nB|---------10---------10-8---|\nG|---------------------------|\n (G mixolydian: G A B C D E F)',
tip: 'Mixolydian = major scale with b7. It\'s the natural scale over any dominant 7th chord — the b7 note is the defining colour.',
},
],
jamTips: [
'In a 12-bar blues, every chord is a dom7. The notes that shift between I7, IV7, and V7 are just the 3rd and b7 — learn those for each chord and you can navigate the whole form.',
'The b7 note is the single best note to target in a fill over a dom7. It\'s immediately recognisable as the "blues note."',
'On the V7 (the most tense moment), one well-placed phrase hits harder than ten frantic notes. Listen to what B.B. King leaves out.',
],
loopPractice: [
{ title: '12-bar blues loop', body: 'Record a 2-bar I7 chord shuffle (e.g. E7 for 2 bars). Loop it and solo using the minor pentatonic. Then add the major 3rd occasionally (in E: G#) — you\'re now mixing major and minor pentatonic, which is the blues scale. Notice how that one note shifts the whole flavour.' },
],
},
maj7: {
jamRole: 'The sophisticated home chord of jazz, bossa nova, and soul. It floats rather than resolves — gives the jam a dreamy, suspended quality. Play it quietly and let it hang.',
voicings: [
{ name: 'Open Cmaj7 / Amaj7 / Emaj7', use: 'Acoustic and light electric jams. Beautiful resonance. Let it ring — the maj7 note needs space to project.' },
{ name: '4-string mid-neck (no 5th)', use: 'Jazz comping. Drop the 5th — it clutters. Root, 3rd, and maj7 is more elegant and sits better under a soloist.' },
{ name: 'High-neck partial (strings 14)', use: 'Comping behind a vocalist. High, bright, completely out of the bass range. Keeps you from competing with anything.' },
],
licks: [
{
title: 'Maj7 arpeggio climb',
style: 'Jazz / Bossa',
tab: 'e|--------9----------|\nB|------9---9--------|\nG|----9-------9------|\nD|--10-----------10--|\nA|------------------|\n (Cmaj7: CEGB)',
tip: 'Land on the maj7 (the B over Cmaj7) — it\'s the characteristic note. Resolve it gently, don\'t snap down.',
},
{
title: 'Lydian colour phrase',
style: 'Jazz',
tab: 'e|--9-10-12-10--9---------|\nB|----------------10-9----|\nG|----------------------9-|\n (#4 = Lydian mode over maj7 — the raised 4th creates a dreamy, floating quality)',
tip: 'The #4 (raised 4th) is the Lydian note — one semitone above the perfect 4th. It creates instant "floating" colour over any maj7 chord.',
},
],
jamTips: [
'Maj7 sounds strongest quiet — dial back your volume. It\'s a listening chord that rewards the band for playing softly.',
'In bossa nova, emphasise the bass note on beat 1 with your thumb and comp the chord on the upbeats with your fingers. No plectrum.',
'The maj7 is one semitone below the root. If you play the root too prominently in the voicing, the 7th disappears. Favour the 3rd and 7th in your note choices.',
],
loopPractice: [
{ title: 'Bossa nova comp loop', body: 'Record a 4-bar maj7 chord using bossa rhythm (bass on beat 1, chord on the "and"). Play it at half the volume you think you need. Then improvise a simple 34 note melody over it. Fewer notes = more space = better bossa feel.' },
],
},
min7: {
jamRole: 'The smoothed-out minor — soul, R&B, funk, and jazz all live here. It invites groove without demanding resolution. Great for long vamps and ii chord setups in jazz.',
voicings: [
{ name: 'Open Am7 / Em7 / Dm7', use: 'Folk, acoustic, and light jazz. Clean and resonant. Good starting point — the open strings blend naturally with the chord.' },
{ name: 'Barre mid-neck (muted)', use: 'Rock-funk. Mute slightly with your palm for a tighter, more percussive sound. Useful for rhythmic comping in a band.' },
{ name: '4-string funk stab (high position)', use: 'THE funk voicing. 4 strings, quick stab and mute. Stay high on the neck — leave the low end for the bass. This is how you comp in a funk jam.' },
],
licks: [
{
title: 'Dorian funk phrase',
style: 'Funk / Jazz',
tab: 'e|---------------------|\nB|---------------------|\nG|--5-7-5-7-9-7-5------|\nD|--5-7-5-7---7-5------|\nA|--3------------------|\n (Am Dorian: natural 6th = F# = fret 9 on G string)',
tip: 'The natural 6th (raised from the plain minor) is the single note that turns a minor vamp into a funk groove. Target it.',
},
{
title: 'Min7 arpeggio fill',
style: 'Jazz / Soul',
tab: 'e|------8----------|\nB|----8---8--------|\nG|--7-------7-5----|\nD|--7-----------7--|\n (Am7: ACEG)',
tip: 'In jazz, the b7 (G over Am7) is the departure note — it creates the sense of movement to the V chord. Let it hang.',
},
],
jamTips: [
'In a funk jam, min7 stabs go on the upbeats. The muted scratches between are as musical as the chords themselves — they hold the groove.',
'In jazz, min7 is almost always the ii chord — expect a dom7 to follow. Your job on ii is to build tension so the V chord feels even better.',
'On a long minor 7th vamp, the Dorian mode (natural 6th) is your key. It\'s what separates "sad minor" from "soulful groove".',
],
loopPractice: [
{ title: 'Funk stab loop', body: 'Record a 2-bar pattern: stab the min7 on the "and" of each beat, leaving the beats empty. Loop it. Add a second loop layer with a single bass note on beats 1 and 3. Hear how it locks into a groove. Then try improvising single notes on top.' },
],
},
dim: {
jamRole: 'Almost never a destination — diminished is a bridge. In a live jam, use it to glide chromatically from one chord to another a half step away. One beat, maximum drama, then move.',
voicings: [
{ name: 'Chromatic passing shape', use: 'Move any dim shape up one fret to arrive at the next chord. This is how diminished works in practice — a one-fret slide between two stable chords.' },
{ name: 'Compact 4-string voicing', use: 'Jazz context where you want the dim colour without full 6-string weight. Cleaner in a dense mix.' },
],
licks: [
{
title: 'Chromatic approach line',
style: 'Jazz / Classical',
tab: 'e|--8-9-10-----------|\nB|------10-11-12-----|\nG|---------------11--|\n (chromatic passing: approach Cmaj7 from B dim)',
tip: 'Each note moves up by exactly one semitone. The half-step motion is what creates the magnetic pull toward the landing chord.',
},
{
title: 'Symmetric dim arpeggio',
style: 'Jazz',
tab: 'e|--4-7-10-----------|\nB|------10-13--------|\nG|---------12--------|\nD|--5-8--------------|\n (B dim: BDFAb — all minor 3rds apart)',
tip: 'Move this pattern up 3 frets and you have the same chord in a new inversion. Exploit the symmetry.',
},
],
jamTips: [
'Use dim as a chromatic approach: going from G to Am, insert G#dim for one beat. The half-step movement makes the landing feel inevitable.',
'In jazz, you can substitute a dim7 a half step above the V chord — it creates even more tension before the resolution.',
'Diminished chords are brief. Never sit on one in a jam — play it quickly and move. It\'s a dramatic pivot, not a home.',
],
loopPractice: [
{ title: 'Chromatic approach exercise', body: 'Record a 2-bar Am loop. On the last beat before the loop repeats, land on G#dim for just one beat before the Am hits. Loop it. Feel how the approach makes the Am feel like a release. Try with different target chords.' },
],
},
dim7: {
jamRole: 'The most symmetric chord in music — one shape, four roots. Use it as a dramatic pivot or tension builder before any chord. In a jazz jam it\'s a secret harmonic weapon.',
voicings: [
{ name: 'Moveable box (any position)', use: 'Move up 3 frets for each inversion — same notes, different voice. Use whichever inversion sits closest to the chord you\'re resolving to.' },
{ name: 'High-neck 4-string', use: 'Clean and tight. Good for jazz where you want the tension colour without orchestral weight. The top string carries the characteristic tritone.' },
],
licks: [
{
title: 'Dim7 arpeggio (symmetric)',
style: 'Jazz / Classical',
tab: 'e|--1-4-7-10--------|\nB|----------10-13---|\nG|------1-4---------|\nD|--2-5-------------|\n (Bdim7: all notes 3 semitones apart)',
tip: 'Every note is an equal minor 3rd apart. Moving up 3 frets lands you on the same chord — use this to shift register dramatically.',
},
{
title: 'Chromatic resolution',
style: 'Jazz',
tab: 'e|--1-0--|\nB|--2-1--|\nG|--1-0--|\nD|--3-2--|\nA|--3-3--|\n Bdim7 → Cmaj7 (half-step resolution)',
tip: 'Each voice moves by just one semitone. This is why dim7 resolves so smoothly — voice leading at its most efficient.',
},
],
jamTips: [
'A dim7 shape a half step above your target chord creates maximum tension before resolution. Before Cmaj7, play Bdim7 for one beat — the release will feel enormous.',
'Every dim7 is an inversion of 3 others. Learn one shape and you have 12 roots covered — just move 3 frets at a time.',
'In a jazz jam, place dim7 on the last beat of a phrase, not the first. The tension arrives just before the new section resolves.',
],
loopPractice: [
{ title: 'Resolution contrast loop', body: 'Record a 4-bar loop: 3 bars Cmaj7, then exactly 1 beat of Bdim7 on the final "and" before the loop repeats. The contrast will be striking every time. Try different dim7 → resolution pairs to hear how universally this works.' },
],
},
half_dim: {
jamRole: 'The ii chord in minor key jazz — where the tension journey begins. When you play this in a jam, you\'re saying "we\'re heading somewhere" and everyone leans in to hear where.',
voicings: [
{ name: 'Standard (5th-string root)', use: 'Most common voicing. The b5 on top is the characteristic tension note — let it ring clearly so everyone hears the harmonic direction.' },
{ name: 'Compact 4-string (b3b5b7)', use: 'Jazz comping. Root, b3, b5, b7 — tight and dark. Perfect when comping behind a soloist who needs space.' },
],
licks: [
{
title: 'Locrian colour phrase',
style: 'Jazz',
tab: 'e|--7-8-10--------|\nB|--------10-8-7--|\nG|--7-8-----------|\nD|--7-------------|\n (Bm7b5: Locrian mode — b2, b3, b5, b7)',
tip: 'The b5 is the defining note. Feature it in your phrase — it\'s what signals "half-diminished" to every musician in the room.',
},
{
title: 'iiVi connection line',
style: 'Jazz',
tab: 'e|------7-8-10----------10-9---------|\nB|----8---------8-10-12------12-10---|\nG|--9---------------------------------|\n (Bm7b5 → E7 → Am: melodic line through the iiVi)',
tip: 'This phrase flows across all three chords. That\'s the goal: melodic lines that connect chords, not stop-start at each change.',
},
],
jamTips: [
'The m7b5 almost always precedes a dominant 7th a 4th above it. If you\'re on Bm7b5, expect E7 next. Learn to anticipate — comp toward the V before it arrives.',
'The characteristic note is the b5. Target it in your fills — it\'s the note that makes the chord sound "yearning" rather than just dark.',
'In jazz comping, your chord stab on the ii should feel like tension loading. Don\'t resolve early — let the V chord do that work.',
],
loopPractice: [
{ title: 'Minor iiVi drill', body: 'Record a 4-bar loop: 2 bars Bm7b5, 1 bar E7, 1 bar Am7. Practise this in Am until it\'s automatic, then transpose to other keys. Every jazz standard is built on variations of this — knowing it in all 12 keys is the single biggest unlock in jazz.' },
],
},
aug: {
jamRole: 'A single beat of suspended tension. Augmented chords are almost always passing — one beat before resolving. In a jam, use one to add forward harmonic motion between two stable chords.',
voicings: [
{ name: 'Standard augmented shape (moveable)', use: 'It\'s symmetric — any inversion works. Use whichever position sits closest to where you\'re going next.' },
{ name: 'Partial 4-string', use: 'Lighter touch. Use in a sparse jam where you want the aug colour without weight. The raised 5th on top is the defining note — keep it.' },
],
licks: [
{
title: 'Whole-tone run',
style: 'Jazz',
tab: 'e|--5-7-9-10-12------|\nB|-------------12----|\nG|--4-6-8------------|\n (whole-tone scale: all major 2nds — the natural scale over augmented)',
tip: 'Every interval is a whole step. The scale has no "home" and sounds disorienting — which is exactly the aug chord feeling. Use it briefly then land on something stable.',
},
{
title: 'I → aug → IV resolution',
style: 'Jazz / Pop',
tab: 'e|--0---0---1--|\nB|--1---1---1--|\nG|--0---1---2--|\nD|--2---2---3--|\nA|--3---3---3--|\nE|--x---x---x--|\n Cmaj Caug F',
tip: 'The aug chord is only one note different from the major — the 5th raised by one semitone. That one note creates all the forward motion into the IV.',
},
],
jamTips: [
'The raised 5th wants to move up by a half step — follow it. C aug → F: the G# in Caug resolves naturally to A (the 3rd of F). That voice leading is the whole point.',
'In jazz, if you hear an aug chord, stay light and brief. One voicing, then move — it\'s a raised eyebrow, not a full statement.',
'Augmented works as a substitute for the V chord — both create tension that resolves to I. Try it in a major key jam as an unexpected colour.',
],
loopPractice: [
{ title: 'I → aug → IV loop', body: 'Record a 4-bar loop: 1.5 bars C major, 0.5 bar Caug, 2 bars F. The augmented is just a moment — practise landing cleanly on the F. Once locked in, add a melodic phrase that follows the aug note (G#) upward to A on the F chord.' },
],
},
sus4: {
jamRole: 'Creates anticipation without committing to major or minor. In a jam, use it to build tension before a major resolution, or sustain it for a floating open feel that invites any scale.',
voicings: [
{ name: 'Open sus4 (Dsus4, Asus4)', use: 'Ring out beautifully. Best for creating space and expectation — especially effective in acoustic and ambient jams.' },
{ name: 'Barre sus4 → major', use: 'Rock and pop jams. Use the sus4 → major resolution as a rhythmic gesture: sus4 on the upbeat, major on the downbeat. The landing feels like a musical exhale.' },
{ name: 'Open string drone + sus4 shape', use: 'Andy Summers / Sting technique. Let high E or B ring open while the fretted notes move. Lush complexity from simple shapes — works beautifully with delay.' },
],
licks: [
{
title: 'Sus4 → major rhythmic gesture',
style: 'Rock / Pop',
tab: 'e|--3---2---3---2--|\nB|--3---3---3---3--|\nG|--2---2---2---2--|\nD|--0---0---0---0--|\n (Dsus4 → D → Dsus4 → D)',
tip: 'The 4th (that one suspended note) resolves down to the 3rd. Let the sus4 hang for a full beat before resolving — the longer the tension, the sweeter the landing.',
},
{
title: 'Open drone riff',
style: 'Ambient / Rock',
tab: 'e|--0---0---0---0--|\nB|--5---5---3---3--|\nG|--6---6---4---4--|\nD|--7---7---5---5--|\nA|--7---7---5---5--|\nE|--5---5---3---3--|\n (C#sus4 riff — open high E rings throughout)',
tip: 'The open high E string is the constant — it creates the suspension as the chord changes underneath it. This is The Police\'s entire harmonic language.',
},
],
jamTips: [
'Sus4 is most powerful just before resolving — the longer you hold it, the more satisfying the major landing. Use it on the upbeat, resolve on the downbeat.',
'In a jam, switching to sus4 on the I chord while others keep playing instantly changes the feel without a real chord change. Subtle and powerful.',
'Pair sus4 with a delay effect (or the loop station reverb tail) for ambient, textural comping that leaves maximum space for other musicians.',
],
loopPractice: [
{ title: 'Tension-release loop', body: 'Record a 2-bar loop: 1 bar sus4, 1 bar major (e.g. Dsus4 → D). Improvise over it, emphasising the 4th over the sus4 bar and resolving to the 3rd on the major bar — your melody mirrors the exact motion of the chord. Teach your ear to hear and resolve suspension.' },
],
},
sus2: {
jamRole: 'The neutral canvas. Sus2 removes the 3rd — it\'s neither major nor minor. In a jam, it gives any soloist total freedom and creates a floating, modern texture that never clashes.',
voicings: [
{ name: 'Open Asus2 / Dsus2', use: 'Key voicings. Open strings ring freely and the chord floats. Best in quieter or ambient jams — don\'t fight it with a heavy strum.' },
{ name: 'Capo + open sus2 shape', use: 'Any key, same open feel. Capo up and play Dsus2 or Asus2 shapes. Constant by Sting, Coldplay, The Edge — sus2 resonance in any key.' },
],
licks: [
{
title: 'Sus2 arpeggio (floating)',
style: 'Ambient / Pop',
tab: 'e|--0-----------0--|\nB|--0---0---0------|\nG|----2---2--------|\nD|------2----------|\nA|--0-----------0--|\n (Asus2: ABE — root, 2nd, 5th)',
tip: 'The 2nd (9th) is the highest note — feature it in your voicing. It\'s the note that makes the chord float.',
},
{
title: 'Single note melody over sus2 drone',
style: 'Ambient',
tab: 'e|--0-2-3-5-3-2-0--|\nB|--0-----------0--|\nG|--2-----------2--|\nD|--2-----------2--|\nA|--0-----------0--|\n (Asus2 held, melody on string 1)',
tip: 'Because there\'s no 3rd in the sus2, any major or minor scale note works as a melody. Total harmonic freedom.',
},
],
jamTips: [
'Sus2 has no 3rd — soloists can play major OR minor scales over it and neither will clash. Use it as a harmonic reset between more defined chords.',
'In a dense jam, switching from a full major chord to a sus2 is like opening a window — everyone hears and appreciates the space.',
'The 9th (the 2nd up an octave) is your featured note. Build your voicing so it sits on top — that\'s what gives the chord its shimmer.',
],
loopPractice: [
{ title: 'Neutral canvas loop', body: 'Record a sparse 4-bar sus2 loop — just a few arpeggiated notes, played quietly. Then add a second loop layer with a single sustained melody note. Try different melody notes and notice that none of them clash. That\'s the power of the sus2 as a backdrop.' },
],
},
maj6: {
jamRole: 'Sweetened major — used in jazz, bossa nova, and vintage pop where a plain major feels too simple. Lush and nostalgic, it sits perfectly under a vocalist or a soloist\'s upper-register lines.',
voicings: [
{ name: 'Open C6 / A6', use: 'Classic jazz and bossa starting points. Bright and warm — good comp chord for a vocalist. The 6th sits naturally on top.' },
{ name: '4-string voicing (no 5th, 6th on top)', use: 'Jazz comping — elegant and uncluttered. Drop the 5th, feature the 6th as the top note. This is how the chord sings in a jazz context.' },
],
licks: [
{
title: 'Maj6 arpeggio',
style: 'Jazz / Bossa',
tab: 'e|--5-----------|\nB|----5---------|\nG|------5-4-2---|\nD|----------4-2-|\nA|--3-----------|\n (C6: CEGA, target the 6th = A)',
tip: 'The 6th (A over C6) is the top of the arpeggio — treat it as the melody note and let everything else support it.',
},
{
title: 'Bossa comp rhythm',
style: 'Bossa Nova',
tab: 'Thumb (T) on bass string, fingers (f) on chord:\n T . f . T f . f | T . f . T f . f\n 1 . + . 2 + . + | 1 . + . 2 + . +\n (the classic João Gilberto bossa strum)',
tip: 'The thumb bass is as important as the chord — it defines the bossa feel. Keep it locked with the kick drum if there is one.',
},
],
jamTips: [
'Maj6 and min7 are inversions of each other — C6 and Am7 share the same four notes. This means you can substitute one for the other in many contexts.',
'Voice the chord so the 6th sits on top — it\'s the melody note. A maj6 with the root on top just sounds like a major chord with extra notes.',
'In bossa nova, the I chord is often maj6 (not maj7). The 6th is sweeter and less mysterious than the 7th — it resolves more completely.',
],
loopPractice: [
{ title: 'Bossa iiVI loop', body: 'Record a 4-bar loop: 1 bar Dm7, 1 bar G7, 2 bars Cmaj6. The most common jazz cadence. Practise until each change is effortless, then improvise a simple melody that lands on the 6th of the Cmaj6 as its final note — the sweetest possible resolution.' },
],
},
min6: {
jamRole: 'Minor with unexpected brightness — the Dorian chord. The major 6th over a minor chord creates emotional complexity: dark and light at once. Essential in flamenco, tango, jazz, and anything Dorian.',
voicings: [
{ name: 'Open Am6 (x02212)', use: 'The classic voicing. The F# on top is the 6th — it must ring clearly. This is the chord that defines the flamenco-jazz crossover sound.' },
{ name: 'Jazz tonic min6 (4-string)', use: 'In jazz minor jams, use min6 as the tonic instead of plain minor. It sounds resolved but never bland — more sophisticated than a plain triad.' },
],
licks: [
{
title: 'Dorian descending line',
style: 'Jazz / Funk',
tab: 'e|--5-3-----------|\nB|------5-3-2-----|\nG|----------4-2---|\nD|-----------4-2--|\nA|--0-------------|\n (A Dorian descending: A G F# E D C# B A)',
tip: 'The F# (natural 6th) is what makes this Dorian, not natural minor. Feature it — it\'s the brightness inside the darkness.',
},
{
title: 'Flamenco approach (Am → Am6)',
style: 'Flamenco / Spanish',
tab: 'e|--0---1---0------|\nB|--1---2---1------|\nG|--2---2---2------|\nD|--2---2---2------|\nA|--0---0---0------|\n Am Am6 Am (the 6th appears and disappears)',
tip: 'Letting the Am6 appear for just one beat creates the characteristic Spanish colour — like a flash of sunlight inside the minor mood.',
},
],
jamTips: [
'The natural 6th in a minor chord IS the Dorian mode marker. If the jam is in Dorian (minor with raised 6th), min6 is the correct tonic — play it instead of plain minor.',
'In a jazz minor jam, use min6 as the final resting point of a phrase. It sounds more resolved than min7 (which still wants to move) but darker than a plain major.',
'The contrast between min6 and plain minor in the same phrase is a device — use it intentionally. Go to Am, then Am6, then back. The harmonic colour shifts are micro-movements of emotion.',
],
loopPractice: [
{ title: 'Dorian vamp loop', body: 'Record a 4-bar Am loop — but on bar 3, switch to Am6 instead of plain Am. Notice how the F# lifts the sound. Improvise using the Dorian scale (natural minor with raised 6th) and specifically target the F# in your phrases. That note IS the sound.' },
],
},
add9: {
jamRole: 'The modern open chord — spacious, unambiguous, never clashes. Add9 keeps the clarity of a triad but adds one note of colour. The sound of contemporary rock, pop, and folk jams.',
voicings: [
{ name: 'Open Cadd9 (x32033)', use: 'The quintessential modern voicing. Ring and middle fingers on 3rd fret, open G and high E ring as the 9th. Perfect for pop and acoustic jams.' },
{ name: 'G / Cadd9 (anchor fingers 34)', use: 'Keep ring and pinky on strings 12 (fret 3) for BOTH G and Cadd9. Only your first two fingers move between the chords. This anchor technique is what makes the two chords sound seamless.' },
{ name: 'High position with open string drone', use: 'Use open strings as drones while fretting the add9 shape further up. Creates lush textural layers — very effective in ambient or layered jams.' },
],
licks: [
{
title: 'Cadd9 arpeggio (the 9th rings)',
style: 'Pop / Folk',
tab: 'e|--3-----------3--|\nB|----3-------3----|\nG|------0---0------|\nD|--------2--------|\nA|--3-----------3--|\n (Cadd9: root → 3rd → 9th — let the 9th ring over the whole phrase)',
tip: 'The 9th (D over C) is the featured note — let it ring as long as possible. It\'s the note that makes the chord feel modern and open.',
},
{
title: 'Melodic fill on string 1 between stabs',
style: 'Rock / Pop',
tab: 'e|--3---5---3---0---3--|\nB|--3-----------3---3--|\nG|--0-----------0---0--|\nD|--2-----------2---2--|\n (Cadd9 chord stab, melody fill, back to chord)',
tip: 'The fill on string 1 sits over the same chord shape — you\'re comping and filling at the same time. This technique makes you sound like two guitarists.',
},
],
jamTips: [
'The Cadd9 ↔ G movement with anchored fingers 34 is the single most useful two-chord pattern in modern pop guitar. Master the anchor first.',
'Unlike maj9, add9 has no 7th — it stays clean and unambiguous in a dense mix. Great when there\'s a keys player already adding 7ths.',
'Let the 9th ring as a pedal note in your fills. The 2nd degree of the scale — heard an octave up — sounds modern every time and never conflicts with the harmony.',
],
loopPractice: [
{ title: 'Anchor pattern loop', body: 'Record a 2-bar loop alternating Cadd9 and G, using the anchor technique (fingers 34 never lift). When the transition is smooth, start adding single-note fills on string 1 between the chord stabs — a lick on the "and" of beat 2, back to the chord on beat 3. You\'re now comping and leading simultaneously.' },
],
},
}
+359
View File
@@ -0,0 +1,359 @@
import { useRef, useState, useCallback, useEffect } from 'react'
const INITIAL_SLOT_COUNT = 4
// ─── Helpers ─────────────────────────────────────────────────────────────────
function makeWaveform(audioBuffer, points = 200) {
const data = audioBuffer.getChannelData(0)
const step = Math.max(1, Math.floor(data.length / points))
const out = new Float32Array(points)
for (let i = 0; i < points; i++) {
let max = 0
for (let j = 0; j < step; j++) {
const v = Math.abs(data[i * step + j] ?? 0)
if (v > max) max = v
}
out[i] = max
}
const peak = Math.max(...out, 0.001)
for (let i = 0; i < points; i++) out[i] /= peak
return out
}
function makeEmptySlot() {
return {
status: 'empty', // 'empty'|'recording'|'trimming'|'playing'|'muted'
audioBuffer: null,
originalBuffer: null, // always the full recording, never overwritten
waveform: null, // Float32Array(200) for canvas display
trimStart: 0,
trimEnd: 1,
volume: 0.8,
sourceNode: null,
gainNode: null,
recordingDuration: 0,
}
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useLoopEngine(bpm) {
const audioCtxRef = useRef(null)
const masterStartRef = useRef(null) // AudioContext timestamp of first loop start
const masterLenRef = useRef(null) // master loop length in seconds
const recorderRef = useRef(null) // { mr, chunks, slotIdx }
const slotsRef = useRef(null)
const streamRef = useRef(null)
const timerRef = useRef(null)
const [slots, setSlots] = useState(() => {
const s = Array.from({ length: INITIAL_SLOT_COUNT }, makeEmptySlot)
slotsRef.current = s
return s
})
const [masterLen, setMasterLen] = useState(null) // mirrors masterLenRef for display
const updateSlot = useCallback((idx, patch) => {
setSlots(prev => {
const next = prev.map((s, i) => i === idx ? { ...s, ...patch } : s)
slotsRef.current = next
return next
})
}, [])
// ── AudioContext ────────────────────────────────────────────────────────────
const ensureCtx = useCallback(() => {
if (!audioCtxRef.current || audioCtxRef.current.state === 'closed') {
audioCtxRef.current = new AudioContext()
}
if (audioCtxRef.current.state === 'suspended') {
audioCtxRef.current.resume().catch(() => {})
}
return audioCtxRef.current
}, [])
// ── Called by AudioCapture when mic stream is ready ─────────────────────────
const setStream = useCallback((stream) => {
streamRef.current = stream
ensureCtx() // pre-warm so first record click has no AudioContext startup lag
}, [ensureCtx])
// ── Start recording on a slot ───────────────────────────────────────────────
const startRecord = useCallback((slotIdx) => {
if (recorderRef.current) return // one at a time
if (!streamRef.current) return
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm'
const mr = new MediaRecorder(streamRef.current, { mimeType })
const chunks = []
mr.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data) }
mr.onstop = async () => {
clearInterval(timerRef.current)
timerRef.current = null
// Guard: was this slot cancelled before onstop fired?
if (slotsRef.current[slotIdx]?.status !== 'recording') return
try {
const ctx = ensureCtx()
const blob = new Blob(chunks, { type: mimeType })
const arrayBuffer = await blob.arrayBuffer()
const audioBuffer = await ctx.decodeAudioData(arrayBuffer)
const waveform = makeWaveform(audioBuffer)
updateSlot(slotIdx, {
status: 'trimming',
audioBuffer,
originalBuffer: audioBuffer, // preserve forever
waveform,
trimStart: 0,
trimEnd: 1,
})
} catch (err) {
console.warn('[LoopEngine] decode failed:', err)
updateSlot(slotIdx, makeEmptySlot())
}
recorderRef.current = null
}
mr.onerror = () => {
clearInterval(timerRef.current)
timerRef.current = null
recorderRef.current = null
updateSlot(slotIdx, makeEmptySlot())
}
recorderRef.current = { mr, chunks, slotIdx }
mr.start(250)
updateSlot(slotIdx, { status: 'recording', recordingDuration: 0 })
const t0 = Date.now()
timerRef.current = setInterval(() => {
updateSlot(slotIdx, { recordingDuration: (Date.now() - t0) / 1000 })
}, 100)
}, [ensureCtx, updateSlot])
// ── Stop recording → triggers onstop → trimming state ──────────────────────
const stopRecord = useCallback((slotIdx) => {
const rec = recorderRef.current
if (!rec || rec.slotIdx !== slotIdx) return
if (rec.mr.state !== 'inactive') rec.mr.stop()
clearInterval(timerRef.current)
timerRef.current = null
}, [])
// ── Cancel recording (discard) ──────────────────────────────────────────────
const cancelRecord = useCallback((slotIdx) => {
const rec = recorderRef.current
if (rec && rec.slotIdx === slotIdx) {
// Prevent onstop from processing by marking slot empty first
updateSlot(slotIdx, makeEmptySlot())
if (rec.mr.state !== 'inactive') rec.mr.stop()
recorderRef.current = null
} else {
updateSlot(slotIdx, makeEmptySlot())
}
clearInterval(timerRef.current)
timerRef.current = null
}, [updateSlot])
// ── Commit trim and start looping ───────────────────────────────────────────
const commitTrim = useCallback((slotIdx, trimStart, trimEnd) => {
const ctx = ensureCtx()
const slot = slotsRef.current[slotIdx]
if (!slot?.audioBuffer) return
const fullBuf = slot.audioBuffer
const sr = fullBuf.sampleRate
const startSample = Math.floor(trimStart * fullBuf.length)
const endSample = Math.ceil(trimEnd * fullBuf.length)
const rawLen = endSample - startSample
if (rawLen < sr * 0.2) return // reject clips shorter than 200ms
const rawData = fullBuf.getChannelData(0).slice(startSample, endSample)
const rawSec = rawLen / sr
// Master loop length is set exactly from the first loop's trimmed region.
// Subsequent loops are padded/trimmed to match that length.
const isFirst = masterLenRef.current === null
const targetSec = isFirst ? rawSec : masterLenRef.current
const targetLen = Math.round(targetSec * sr)
const finalData = new Float32Array(targetLen) // zero-padded by default
finalData.set(rawData.slice(0, Math.min(rawLen, targetLen)))
const finalBuf = ctx.createBuffer(1, targetLen, sr)
finalBuf.copyToChannel(finalData, 0)
if (isFirst) {
masterLenRef.current = targetSec
setMasterLen(targetSec)
}
const gainNode = ctx.createGain()
gainNode.gain.value = slot.volume
gainNode.connect(ctx.destination)
const sourceNode = ctx.createBufferSource()
sourceNode.buffer = finalBuf
sourceNode.loop = true
sourceNode.loopStart = 0
sourceNode.loopEnd = targetSec
sourceNode.connect(gainNode)
// Schedule: first loop starts immediately; subsequent loops snap to the
// next master loop boundary so they play in perfect sync.
let startTime
if (isFirst) {
startTime = ctx.currentTime + 0.05
masterStartRef.current = startTime
} else {
const elapsed = ctx.currentTime - masterStartRef.current
const loopLen = masterLenRef.current
const posInLoop = elapsed % loopLen
const remaining = loopLen - posInLoop
const LOOKAHEAD = 0.05
startTime = ctx.currentTime + (remaining > LOOKAHEAD ? remaining : remaining + loopLen)
}
sourceNode.start(startTime)
updateSlot(slotIdx, {
status: 'playing',
audioBuffer: finalBuf,
originalBuffer: slot.originalBuffer ?? null, // preserve original
waveform: makeWaveform(finalBuf),
trimStart: 0,
trimEnd: 1,
sourceNode,
gainNode,
})
}, [ensureCtx, updateSlot])
// ── Re-trim: return a playing/muted slot back to the trimmer ────────────────
const retrimSlot = useCallback((slotIdx) => {
const slot = slotsRef.current[slotIdx]
if (!slot?.originalBuffer) return
// Stop current playback
try { slot.sourceNode?.stop() } catch {}
try { slot.sourceNode?.disconnect() } catch {}
try { slot.gainNode?.disconnect() } catch {}
// Reset master timing if no other loops remain active
const hasOthers = slotsRef.current.some((s, i) =>
i !== slotIdx && (s.status === 'playing' || s.status === 'muted')
)
if (!hasOthers) {
masterStartRef.current = null
masterLenRef.current = null
setMasterLen(null)
}
updateSlot(slotIdx, {
status: 'trimming',
audioBuffer: slot.originalBuffer,
waveform: makeWaveform(slot.originalBuffer),
originalBuffer: slot.originalBuffer,
trimStart: 0,
trimEnd: 1,
sourceNode: null,
gainNode: null,
})
}, [updateSlot])
// ── Toggle mute ─────────────────────────────────────────────────────────────
const toggleMute = useCallback((slotIdx) => {
const ctx = audioCtxRef.current
const slot = slotsRef.current[slotIdx]
if (!slot || !ctx) return
if (slot.status !== 'playing' && slot.status !== 'muted') return
const muting = slot.status === 'playing'
slot.gainNode?.gain.setTargetAtTime(muting ? 0 : slot.volume, ctx.currentTime, 0.02)
updateSlot(slotIdx, { status: muting ? 'muted' : 'playing' })
}, [updateSlot])
// ── Delete slot ─────────────────────────────────────────────────────────────
const deleteSlot = useCallback((slotIdx) => {
const slot = slotsRef.current[slotIdx]
if (!slot) return
if (slot.status === 'recording') cancelRecord(slotIdx)
try { slot.sourceNode?.stop() } catch {}
try { slot.sourceNode?.disconnect() } catch {}
try { slot.gainNode?.disconnect() } catch {}
// Reset master timing when no loops remain active
const hasOthers = slotsRef.current.some((s, i) =>
i !== slotIdx && (s.status === 'playing' || s.status === 'muted')
)
if (!hasOthers) {
masterStartRef.current = null
masterLenRef.current = null
setMasterLen(null)
}
updateSlot(slotIdx, makeEmptySlot())
}, [cancelRecord, updateSlot])
// ── Volume ──────────────────────────────────────────────────────────────────
const setVolume = useCallback((slotIdx, vol) => {
const ctx = audioCtxRef.current
const slot = slotsRef.current[slotIdx]
if (slot?.gainNode && ctx && slot.status === 'playing') {
slot.gainNode.gain.setTargetAtTime(vol, ctx.currentTime, 0.02)
}
updateSlot(slotIdx, { volume: vol })
}, [updateSlot])
// ── Main click handler (drives the state machine) ───────────────────────────
const handleSlotClick = useCallback((slotIdx) => {
const slot = slotsRef.current[slotIdx]
if (!slot) return
switch (slot.status) {
case 'empty': startRecord(slotIdx); break
case 'recording': stopRecord(slotIdx); break
case 'playing':
case 'muted': toggleMute(slotIdx); break
// 'trimming' is handled entirely by LoopTrimmer
}
}, [startRecord, stopRecord, toggleMute])
// ── Add a new empty slot ────────────────────────────────────────────────────
const addSlot = useCallback(() => {
setSlots(prev => {
const next = [...prev, makeEmptySlot()]
slotsRef.current = next
return next
})
}, [])
// ── Cleanup ─────────────────────────────────────────────────────────────────
useEffect(() => {
return () => {
clearInterval(timerRef.current)
slotsRef.current?.forEach(s => {
try { s.sourceNode?.stop() } catch {}
try { s.sourceNode?.disconnect() } catch {}
try { s.gainNode?.disconnect() } catch {}
})
try { audioCtxRef.current?.close() } catch {}
}
}, [])
return {
slots,
masterLen,
setStream,
handleSlotClick,
commitTrim,
cancelRecord,
retrimSlot,
deleteSlot,
setVolume,
addSlot,
audioCtxRef,
masterStartRef,
masterLenRef,
}
}