loop station
This commit is contained in:
+38
-8
@@ -11,8 +11,9 @@ import DebugView from './components/DebugView'
|
|||||||
import DrumView from './components/DrumView'
|
import DrumView from './components/DrumView'
|
||||||
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
import { NOTES, detectKey, detectTopKeys, matchChordFromChroma, detectRepeatingProgression, getChordTones, getChordCandidates, getNoteHistoryAnalysis } from './lib/theory'
|
||||||
import ChordDetailModal from './components/ChordDetailModal'
|
import ChordDetailModal from './components/ChordDetailModal'
|
||||||
import ExplorePanel from './components/ExplorePanel'
|
|
||||||
import CurrentJamPanel from './components/CurrentJamPanel'
|
import CurrentJamPanel from './components/CurrentJamPanel'
|
||||||
|
import LoopStation from './components/LoopStation'
|
||||||
|
import { useLoopEngine } from './services/loopEngine'
|
||||||
import settingIcon from './assets/setting-icon.png'
|
import settingIcon from './assets/setting-icon.png'
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
@@ -83,6 +84,23 @@ export default function App() {
|
|||||||
const onsetTimestampsRef = useRef([])
|
const onsetTimestampsRef = useRef([])
|
||||||
const bpmSmoothRef = useRef(null)
|
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 ───────────────────────────────────────
|
// ── Key: auto-detected + optional lock ───────────────────────────────────────
|
||||||
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
|
const [keyInfo, setKeyInfo] = useState(null) // auto-detected
|
||||||
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
|
const [lockedKey, setLockedKey] = useState(null) // { root, mode } or null
|
||||||
@@ -525,6 +543,7 @@ export default function App() {
|
|||||||
setMicError(true)
|
setMicError(true)
|
||||||
setIsListening(false)
|
setIsListening(false)
|
||||||
}}
|
}}
|
||||||
|
onStreamReady={loopSetStream}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{micError && (
|
{micError && (
|
||||||
@@ -535,7 +554,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Chord detail modal ── */}
|
{/* ── 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 ── */}
|
{/* ── Progression banner ── */}
|
||||||
<ProgressionBanner
|
<ProgressionBanner
|
||||||
@@ -561,12 +580,6 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Explore any chord — collapsible ── */}
|
|
||||||
<ExplorePanel
|
|
||||||
keyInfo={effectiveKey}
|
|
||||||
chordHistory={chordHistory}
|
|
||||||
onChordClick={setSelectedChord}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ── Current jam — collapsible ── */}
|
{/* ── Current jam — collapsible ── */}
|
||||||
<CurrentJamPanel
|
<CurrentJamPanel
|
||||||
@@ -576,6 +589,23 @@ export default function App() {
|
|||||||
onChordClick={setSelectedChord}
|
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 ── */}
|
{/* ── Behind the scenes — collapsible ── */}
|
||||||
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ function detectBassPC(freqData, sampleRate, fftSize) {
|
|||||||
return ((bestMidi % 12) + 12) % 12
|
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 audioCtxRef = useRef(null)
|
||||||
const timeBufRef = useRef(null)
|
const timeBufRef = useRef(null)
|
||||||
const freqBufRef = useRef(null)
|
const freqBufRef = useRef(null)
|
||||||
@@ -96,6 +96,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
|||||||
const onOnsetRef = useRef(onOnset)
|
const onOnsetRef = useRef(onOnset)
|
||||||
const onWaveformRef = useRef(onWaveform)
|
const onWaveformRef = useRef(onWaveform)
|
||||||
const onPermissionErrorRef = useRef(onPermissionError)
|
const onPermissionErrorRef = useRef(onPermissionError)
|
||||||
|
const onStreamReadyRef = useRef(onStreamReady)
|
||||||
const minClarityRef = useRef(minClarity)
|
const minClarityRef = useRef(minClarity)
|
||||||
const minVolumeRef = useRef(minVolume)
|
const minVolumeRef = useRef(minVolume)
|
||||||
const smoothRmsRef = useRef(0)
|
const smoothRmsRef = useRef(0)
|
||||||
@@ -106,6 +107,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
|||||||
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
|
useEffect(() => { onOnsetRef.current = onOnset }, [onOnset])
|
||||||
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
|
useEffect(() => { onWaveformRef.current = onWaveform }, [onWaveform])
|
||||||
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
useEffect(() => { onPermissionErrorRef.current = onPermissionError }, [onPermissionError])
|
||||||
|
useEffect(() => { onStreamReadyRef.current = onStreamReady }, [onStreamReady])
|
||||||
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
useEffect(() => { minClarityRef.current = minClarity }, [minClarity])
|
||||||
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
useEffect(() => { minVolumeRef.current = minVolume }, [minVolume])
|
||||||
|
|
||||||
@@ -154,6 +156,7 @@ export default function AudioCapture({ onNote, onChroma, onOnset, onWaveform, is
|
|||||||
}
|
}
|
||||||
streamRef.current = stream
|
streamRef.current = stream
|
||||||
activeRef.current = true
|
activeRef.current = true
|
||||||
|
onStreamReadyRef.current?.(stream)
|
||||||
smoothRmsRef.current = 0
|
smoothRmsRef.current = 0
|
||||||
lastOnsetRef.current = 0
|
lastOnsetRef.current = 0
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import ChordBox from './ChordBox'
|
|||||||
import MiniPiano from './MiniPiano'
|
import MiniPiano from './MiniPiano'
|
||||||
import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings'
|
import { getGuitarVoicings, getPianoTechniques, parseChord } from '../lib/voicings'
|
||||||
import { CHORD_TYPES, NOTES, getChordsInKey, toRomanNumeral, getSuggestedProgressions } from '../lib/theory'
|
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 = [
|
const CHORD_SUFFIX_OPTIONS = [
|
||||||
{ key: 'maj', label: 'Major' },
|
{ key: 'maj', label: 'Major' },
|
||||||
@@ -300,6 +300,324 @@ function ProgressionsSubTab({ chordName, onChordClick }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Theory tab ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const CHORD_THEORY = {
|
||||||
|
maj: {
|
||||||
|
name: 'Major',
|
||||||
|
formula: 'Root + Major 3rd (4 semitones) + Perfect 5th (7 semitones)',
|
||||||
|
vibe: 'Bright, happy, resolved. The most "complete" sound in Western music.',
|
||||||
|
beginner: 'Major chords are the foundation of almost every song you know. They feel stable and uplifting — like a musical full stop.',
|
||||||
|
tension: 'Low — very stable',
|
||||||
|
color: 'text-yellow-400',
|
||||||
|
},
|
||||||
|
min: {
|
||||||
|
name: 'Minor',
|
||||||
|
formula: 'Root + Minor 3rd (3 semitones) + Perfect 5th (7 semitones)',
|
||||||
|
vibe: 'Dark, melancholic, introspective. The 3rd is lowered by just one semitone — that one note changes everything.',
|
||||||
|
beginner: 'One note separates major from minor. Minor chords carry emotion and depth — sadness, mystery, tension.',
|
||||||
|
tension: 'Low-medium — stable but moody',
|
||||||
|
color: 'text-blue-400',
|
||||||
|
},
|
||||||
|
dom7: {
|
||||||
|
name: 'Dominant 7th',
|
||||||
|
formula: 'Major triad + Minor 7th (10 semitones)',
|
||||||
|
vibe: 'Tense, bluesy, urgent. Wants desperately to resolve to a chord a 5th lower.',
|
||||||
|
beginner: 'The 7th chord is the engine of blues and jazz. It creates tension that begs to resolve — like holding your breath. Play G7 then C to feel it.',
|
||||||
|
tension: 'High — strongly pulls to resolution',
|
||||||
|
color: 'text-red-400',
|
||||||
|
},
|
||||||
|
maj7: {
|
||||||
|
name: 'Major 7th',
|
||||||
|
formula: 'Major triad + Major 7th (11 semitones)',
|
||||||
|
vibe: 'Dreamy, lush, sophisticated. Jazz-infused warmth without the tension of a dominant 7th.',
|
||||||
|
beginner: 'The major 7th is the note just below the octave. Adding it to a major chord gives you that smooth jazz-bossa nova sound — think "Autumn Leaves".',
|
||||||
|
tension: 'Very low — ethereal and floating',
|
||||||
|
color: 'text-purple-400',
|
||||||
|
},
|
||||||
|
min7: {
|
||||||
|
name: 'Minor 7th',
|
||||||
|
formula: 'Minor triad + Minor 7th (10 semitones)',
|
||||||
|
vibe: 'Smooth, soulful, relaxed. Darker than major 7th but less tense than a dominant 7th.',
|
||||||
|
beginner: 'Minor 7ths are everywhere in soul, R&B, and jazz. They\'re minor chords with added warmth — moody but not harsh.',
|
||||||
|
tension: 'Low-medium — smooth and flowing',
|
||||||
|
color: 'text-indigo-400',
|
||||||
|
},
|
||||||
|
dim: {
|
||||||
|
name: 'Diminished',
|
||||||
|
formula: 'Root + Minor 3rd (3 semitones) + Diminished 5th (6 semitones)',
|
||||||
|
vibe: 'Dark, tense, unstable. The flattened 5th creates a tritone interval — historically called "diabolus in musica" (the devil in music).',
|
||||||
|
beginner: 'Diminished chords are passing chords — they create maximum tension so the next chord feels like a huge relief. Like a musical cliffhanger.',
|
||||||
|
tension: 'Very high — wants to resolve immediately',
|
||||||
|
color: 'text-orange-400',
|
||||||
|
},
|
||||||
|
dim7: {
|
||||||
|
name: 'Diminished 7th',
|
||||||
|
formula: 'Diminished triad + Diminished 7th (9 semitones) — fully symmetric, all minor 3rds',
|
||||||
|
vibe: 'Extremely tense and dramatic. Used in horror film scores and dramatic classical passages.',
|
||||||
|
beginner: 'All four notes are equally spaced (all minor 3rds apart), making it the most symmetrical and unstable chord. Classic "villain arrives" sound.',
|
||||||
|
tension: 'Extreme — maximum instability',
|
||||||
|
color: 'text-red-600',
|
||||||
|
},
|
||||||
|
half_dim: {
|
||||||
|
name: 'Half-Diminished (m7♭5)',
|
||||||
|
formula: 'Diminished triad + Minor 7th (10 semitones)',
|
||||||
|
vibe: 'Dark and tense but with slightly more resolution than full dim7. The "ii" chord in minor ii–V–i jazz progressions.',
|
||||||
|
beginner: 'Half-diminished sits between a minor 7th and a fully diminished chord. It\'s the moody jazz workhorse — think the intro to "Autumn Leaves".',
|
||||||
|
tension: 'High — tense but musical',
|
||||||
|
color: 'text-orange-500',
|
||||||
|
},
|
||||||
|
aug: {
|
||||||
|
name: 'Augmented',
|
||||||
|
formula: 'Root + Major 3rd (4 semitones) + Augmented 5th (8 semitones) — all major 3rds',
|
||||||
|
vibe: 'Eerie, floating, dreamlike. The raised 5th creates instability that can resolve either up or down.',
|
||||||
|
beginner: 'Augmented chords sound like something is about to happen. They\'re often used as a passing chord between major and minor — the 5th feels like it\'s "reaching" upward.',
|
||||||
|
tension: 'High — ambiguous direction',
|
||||||
|
color: 'text-emerald-400',
|
||||||
|
},
|
||||||
|
sus4: {
|
||||||
|
name: 'Suspended 4th',
|
||||||
|
formula: 'Root + Perfect 4th (5 semitones) + Perfect 5th (7 semitones)',
|
||||||
|
vibe: 'Open, unresolved, expectant. The 3rd is replaced by a 4th — neither major nor minor, just floating.',
|
||||||
|
beginner: '"Sus" means suspended — the 3rd is suspended in mid-air. It wants to drop down to a major or minor chord. Classic rock move: sus4 → major.',
|
||||||
|
tension: 'Medium — pleasant tension, easy on the ear',
|
||||||
|
color: 'text-cyan-400',
|
||||||
|
},
|
||||||
|
sus2: {
|
||||||
|
name: 'Suspended 2nd',
|
||||||
|
formula: 'Root + Major 2nd (2 semitones) + Perfect 5th (7 semitones)',
|
||||||
|
vibe: 'Airy, spacious, ambiguous. Like sus4 but lighter — the 2nd sits high above the root.',
|
||||||
|
beginner: 'Sus2 is a favourite of modern pop and ambient music. Without a 3rd, it has no major/minor quality — it just floats. Think Sting, U2, Coldplay.',
|
||||||
|
tension: 'Low-medium — open and spacious',
|
||||||
|
color: 'text-teal-400',
|
||||||
|
},
|
||||||
|
maj6: {
|
||||||
|
name: 'Major 6th',
|
||||||
|
formula: 'Major triad + Major 6th (9 semitones)',
|
||||||
|
vibe: 'Sweet, vintage, nostalgic. The 6th adds a note from the scale without the tension of a 7th.',
|
||||||
|
beginner: "The 6th is a colour tone that sweetens a major chord. Common in jazz, bossa nova, and 50s pop — \"Misty\" and \"Fly Me To The Moon\" territory.",
|
||||||
|
tension: 'Very low — sweeter than major triad',
|
||||||
|
color: 'text-amber-300',
|
||||||
|
},
|
||||||
|
min6: {
|
||||||
|
name: 'Minor 6th',
|
||||||
|
formula: 'Minor triad + Major 6th (9 semitones)',
|
||||||
|
vibe: 'Bittersweet, exotic, dramatic. A major 6th over a minor chord creates a striking contrast.',
|
||||||
|
beginner: 'Minor 6ths have a flamenco/tango feel. The bright 6th sitting on top of a dark minor chord creates a sophisticated tension — think Django Reinhardt.',
|
||||||
|
tension: 'Medium — intriguing contrast',
|
||||||
|
color: 'text-amber-400',
|
||||||
|
},
|
||||||
|
add9: {
|
||||||
|
name: 'Add 9',
|
||||||
|
formula: 'Major triad + Major 9th (14 semitones = octave + 2)',
|
||||||
|
vibe: 'Open, modern, slightly epic. The 9th adds colour without the smoothness of a 7th.',
|
||||||
|
beginner: 'Add9 is the chord of modern rock and pop. Unlike maj9 (which also has a 7th), add9 keeps things clean and direct. Coldplay, Radiohead, and U2 love it.',
|
||||||
|
tension: 'Very low — bright and open',
|
||||||
|
color: 'text-lime-400',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const INTERVAL_NAMES = {
|
||||||
|
0: 'Root', 2: 'Major 2nd', 3: 'Minor 3rd', 4: 'Major 3rd',
|
||||||
|
5: 'Perfect 4th', 6: 'Tritone (♭5)', 7: 'Perfect 5th',
|
||||||
|
8: 'Aug 5th', 9: 'Major 6th', 10: 'Minor 7th', 11: 'Major 7th',
|
||||||
|
14: 'Major 9th',
|
||||||
|
}
|
||||||
|
|
||||||
|
function TheoryTab({ chordName }) {
|
||||||
|
const parsed = parseChord(chordName)
|
||||||
|
if (!parsed) return <p className="text-gray-500 text-sm text-center py-8">Could not parse chord.</p>
|
||||||
|
|
||||||
|
const { rootPc, type } = parsed
|
||||||
|
const typeInfo = CHORD_TYPES[type]
|
||||||
|
const theory = CHORD_THEORY[type]
|
||||||
|
const subs = getChordSubstitutions(chordName)
|
||||||
|
|
||||||
|
// Actual note names
|
||||||
|
const noteNames = (typeInfo?.intervals ?? []).map(iv => NOTES[(rootPc + iv) % 12])
|
||||||
|
|
||||||
|
// Roles this chord can play
|
||||||
|
const ROLES = []
|
||||||
|
for (let keyPc = 0; keyPc < 12; keyPc++) {
|
||||||
|
for (const mode of ['major', 'minor']) {
|
||||||
|
const diatonicChords = getChordsInKey(NOTES[keyPc], mode)
|
||||||
|
const idx = diatonicChords.indexOf(chordName)
|
||||||
|
if (idx !== -1) {
|
||||||
|
const rn = toRomanNumeral(chordName, NOTES[keyPc], mode)
|
||||||
|
ROLES.push({ keyRoot: NOTES[keyPc], mode, rn })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* ── What is this chord? ── */}
|
||||||
|
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||||
|
<div className="flex items-baseline gap-3 mb-3">
|
||||||
|
<span className={`text-lg font-black ${theory?.color ?? 'text-accent'}`}>{chordName}</span>
|
||||||
|
<span className="text-sm text-gray-400">{theory?.name ?? type}</span>
|
||||||
|
</div>
|
||||||
|
{theory && (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-gray-200 leading-relaxed mb-2">{theory.beginner}</p>
|
||||||
|
<p className="text-xs text-gray-500 italic leading-relaxed">{theory.vibe}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Notes & Formula ── */}
|
||||||
|
<div className="p-4 bg-surface border border-border rounded-xl">
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">Notes in this chord</p>
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{(typeInfo?.intervals ?? []).map((iv, i) => (
|
||||||
|
<div key={i} className={`flex flex-col items-center px-3 py-2 rounded-xl border ${
|
||||||
|
i === 0 ? 'bg-accent/20 border-accent text-accent' : 'bg-panel border-border text-gray-300'
|
||||||
|
}`}>
|
||||||
|
<span className="text-base font-black">{noteNames[i]}</span>
|
||||||
|
<span className="text-[10px] text-gray-500 leading-none mt-0.5">{INTERVAL_NAMES[iv] ?? `+${iv}`}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{theory && (
|
||||||
|
<div className="text-xs text-gray-600 font-mono bg-panel/50 rounded-lg px-3 py-2 border border-border">
|
||||||
|
{theory.formula}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{theory && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-gray-600">Tension:</span>
|
||||||
|
<span className="text-xs text-gray-400">{theory.tension}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Chord substitutions ── */}
|
||||||
|
{subs.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">Colour swaps — try these instead</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{subs.map((sub, i) => (
|
||||||
|
<div key={i} className="flex items-start gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||||
|
<span className="text-sm font-black text-accent shrink-0 w-16">{sub.chord}</span>
|
||||||
|
<p className="text-xs text-gray-400 leading-snug">{sub.tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Keys this chord belongs to ── */}
|
||||||
|
{ROLES.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-3">{chordName} appears in these keys</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{ROLES.slice(0, 10).map(({ keyRoot, mode, rn }) => (
|
||||||
|
<div key={`${keyRoot}-${mode}`}
|
||||||
|
className="px-3 py-2 bg-surface border border-border rounded-xl text-xs flex items-center gap-2">
|
||||||
|
<span className="font-bold text-white">{keyRoot}</span>
|
||||||
|
<span className="text-gray-500 capitalize">{mode}</span>
|
||||||
|
<span className="text-amber-400 font-bold">{rn}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-gray-700 mt-2">
|
||||||
|
Roman numerals show the chord's role: I/i = home, IV = subdominant, V = dominant tension, vi/♭VI = relative minor/major, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Learn tab ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function LearnTab({ chordName }) {
|
||||||
|
const parsed = parseChord(chordName)
|
||||||
|
const playbook = parsed ? CHORD_PLAYBOOK[parsed.type] : null
|
||||||
|
|
||||||
|
if (!playbook) {
|
||||||
|
return <p className="text-gray-500 text-sm text-center py-8">No jam content for {chordName} yet.</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
|
||||||
|
{/* ── Jam role ── */}
|
||||||
|
<div className="px-4 py-3 bg-accent/10 border border-accent/20 rounded-xl">
|
||||||
|
<p className="text-[10px] uppercase tracking-wider text-accent/60 mb-1">Your role in the jam</p>
|
||||||
|
<p className="text-sm text-white leading-relaxed">{playbook.jamRole}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Voicings for jamming ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Voicings — when to use which</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{playbook.voicings.map((v, i) => (
|
||||||
|
<div key={i} className="flex gap-3 p-3 bg-surface border border-border rounded-xl hover:border-accent/20 transition-colors">
|
||||||
|
<span className="text-accent font-black text-lg shrink-0 leading-none mt-0.5">{i + 1}</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-white mb-0.5">{v.name}</p>
|
||||||
|
<p className="text-xs text-gray-400 leading-relaxed">{v.use}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-700 mt-2">See the Guitar tab for the actual fingerings of each shape.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Licks & fills ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Licks & fills</p>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{playbook.licks.map((l, i) => (
|
||||||
|
<div key={i} className="p-4 bg-surface border border-border rounded-xl hover:border-accent/30 transition-colors">
|
||||||
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||||
|
<p className="text-sm font-bold text-white">{l.title}</p>
|
||||||
|
<span className="text-[9px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full bg-accent/10 border border-accent/20 text-accent">{l.style}</span>
|
||||||
|
</div>
|
||||||
|
<pre className="text-[10px] font-mono text-accent/70 bg-black/40 border border-border rounded-lg px-3 py-2 overflow-x-auto leading-relaxed whitespace-pre mb-2">{l.tab}</pre>
|
||||||
|
<p className="text-xs text-amber-400/80">
|
||||||
|
<span className="font-semibold text-amber-400">Key insight: </span>{l.tip}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Jam tips ── */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Jam tips</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{playbook.jamTips.map((tip, i) => (
|
||||||
|
<div key={i} className="flex gap-2.5 text-xs text-gray-300 leading-relaxed p-2.5 rounded-lg bg-surface border border-border">
|
||||||
|
<span className="text-accent shrink-0 font-bold mt-0.5">→</span>
|
||||||
|
<p>{tip}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Loop station practice ── */}
|
||||||
|
{playbook.loopPractice?.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] uppercase tracking-wider text-gray-600 mb-2">Loop station practice</p>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{playbook.loopPractice.map((lp, i) => (
|
||||||
|
<div key={i} className="p-3 bg-surface border border-border rounded-xl border-l-2 border-l-accent/40">
|
||||||
|
<p className="text-xs font-bold text-white mb-1">🔁 {lp.title}</p>
|
||||||
|
<p className="text-xs text-gray-400 leading-relaxed">{lp.body}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Explore tab ──────────────────────────────────────────────────────────────
|
// ─── Explore tab ──────────────────────────────────────────────────────────────
|
||||||
function ExploreTab({ initialChord, keyInfo, chordHistory }) {
|
function ExploreTab({ initialChord, keyInfo, chordHistory }) {
|
||||||
const parsed = parseChord(initialChord)
|
const parsed = parseChord(initialChord)
|
||||||
@@ -372,9 +690,8 @@ function ExploreTab({ initialChord, keyInfo, chordHistory }) {
|
|||||||
{/* ── Sub-tabs ── */}
|
{/* ── Sub-tabs ── */}
|
||||||
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
|
<div className="flex gap-1 bg-surface border border-border rounded-xl p-1 overflow-x-auto">
|
||||||
{[
|
{[
|
||||||
{ key: 'guitar', label: '🎸 Guitar' },
|
{ key: 'guitar', label: '🎸 Guitar' },
|
||||||
{ key: 'piano', label: '🎹 Piano' },
|
{ key: 'piano', label: '🎹 Piano' },
|
||||||
{ key: 'progressions', label: '🎵 Progressions' },
|
|
||||||
].map(t => (
|
].map(t => (
|
||||||
<button key={t.key}
|
<button key={t.key}
|
||||||
onClick={() => setSubTab(t.key)}
|
onClick={() => setSubTab(t.key)}
|
||||||
@@ -386,16 +703,15 @@ function ExploreTab({ initialChord, keyInfo, chordHistory }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{subTab === 'guitar' && <GuitarTab chordName={chordName} />}
|
{subTab === 'guitar' && <GuitarTab chordName={chordName} />}
|
||||||
{subTab === 'piano' && <PianoTab chordName={chordName} />}
|
{subTab === 'piano' && <PianoTab chordName={chordName} />}
|
||||||
{subTab === 'progressions' && <ProgressionsSubTab chordName={chordName} onChordClick={c => selectChord(c)} />}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Main modal ───────────────────────────────────────────────────────────────
|
// ─── Main modal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function ChordDetailModal({ chord, onClose, keyInfo, chordHistory }) {
|
export default function ChordDetailModal({ chord, onClose, onChordClick, keyInfo, chordHistory }) {
|
||||||
const [tab, setTab] = useState('guitar')
|
const [tab, setTab] = useState('guitar')
|
||||||
|
|
||||||
// Reset tab when chord changes
|
// Reset tab when chord changes
|
||||||
@@ -436,15 +752,18 @@ export default function ChordDetailModal({ chord, onClose, keyInfo, chordHistory
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab bar */}
|
{/* 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: 'guitar', label: '🎸 Guitar' },
|
||||||
{ key: 'piano', label: '🎹 Piano Techniques' },
|
{ key: 'piano', label: '🎹 Piano' },
|
||||||
{ key: 'explore', label: '🔍 Explore Any Chord' },
|
{ key: 'theory', label: '📚 Theory' },
|
||||||
|
{ key: 'learn', label: '🎓 Learn' },
|
||||||
|
{ key: 'progressions', label: '🎵 Progressions' },
|
||||||
|
{ key: 'explore', label: '🔍 Explore' },
|
||||||
].map(t => (
|
].map(t => (
|
||||||
<button key={t.key}
|
<button key={t.key}
|
||||||
onClick={() => setTab(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
|
tab === t.key
|
||||||
? 'text-accent border-accent bg-accent/10'
|
? 'text-accent border-accent bg-accent/10'
|
||||||
: 'text-gray-500 border-transparent hover:text-gray-300'
|
: 'text-gray-500 border-transparent hover:text-gray-300'
|
||||||
@@ -456,9 +775,12 @@ export default function ChordDetailModal({ chord, onClose, keyInfo, chordHistory
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="px-6 py-5">
|
<div className="px-6 py-5">
|
||||||
{tab === 'guitar' && <GuitarTab chordName={chord} />}
|
{tab === 'guitar' && <GuitarTab chordName={chord} />}
|
||||||
{tab === 'piano' && <PianoTab chordName={chord} />}
|
{tab === 'piano' && <PianoTab chordName={chord} />}
|
||||||
{tab === 'explore' && <ExploreTab initialChord={chord} keyInfo={keyInfo} chordHistory={chordHistory} />}
|
{tab === 'theory' && <TheoryTab chordName={chord} />}
|
||||||
|
{tab === 'learn' && <LearnTab chordName={chord} />}
|
||||||
|
{tab === 'progressions' && <ProgressionsSubTab chordName={chord} onChordClick={c => { onChordClick?.(c) }} />}
|
||||||
|
{tab === 'explore' && <ExploreTab initialChord={chord} keyInfo={keyInfo} chordHistory={chordHistory} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
const STYLE = {
|
||||||
|
empty: { border: 'border-border', bg: 'bg-surface', icon: '●', iconColor: 'text-gray-700' },
|
||||||
|
recording: { border: 'border-red-500', bg: 'bg-red-950/20', icon: '⏺', iconColor: 'text-red-400' },
|
||||||
|
trimming: { border: 'border-amber-400', bg: 'bg-amber-950/20', icon: '✂', iconColor: 'text-amber-400'},
|
||||||
|
playing: { border: 'border-accent', bg: 'bg-accent/10', icon: '▶', iconColor: 'text-accent' },
|
||||||
|
muted: { border: 'border-border', bg: 'bg-surface', icon: '⏸', iconColor: 'text-gray-500' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABEL = {
|
||||||
|
empty: 'tap to rec',
|
||||||
|
recording: 'tap to stop',
|
||||||
|
trimming: 'trimming…',
|
||||||
|
playing: 'tap to mute',
|
||||||
|
muted: 'tap to play',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoopSlot({ slot, slotIdx, audioCtxRef, masterStartRef, masterLenRef, onClick, onRetrim, onDelete, onVolumeChange }) {
|
||||||
|
const progressRef = useRef(null)
|
||||||
|
const rafRef = useRef(null)
|
||||||
|
const [showVol, setShowVol] = useState(false)
|
||||||
|
const [holdTimer, setHoldTimer] = useState(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const { status, recordingDuration, volume, originalBuffer } = slot
|
||||||
|
const style = STYLE[status] ?? STYLE.empty
|
||||||
|
const isActive = status === 'playing' || status === 'muted'
|
||||||
|
|
||||||
|
// ── Progress bar via rAF ─────────────────────────────────────────────────
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) {
|
||||||
|
if (progressRef.current) progressRef.current.style.width = '0%'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
const ctx = audioCtxRef.current
|
||||||
|
const mStart = masterStartRef.current
|
||||||
|
const mLen = masterLenRef.current
|
||||||
|
if (ctx && mStart !== null && mLen && progressRef.current) {
|
||||||
|
const pos = ((ctx.currentTime - mStart) % mLen) / mLen * 100
|
||||||
|
progressRef.current.style.width = `${pos}%`
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current) }
|
||||||
|
}, [isActive, audioCtxRef, masterStartRef, masterLenRef])
|
||||||
|
|
||||||
|
// ── Long-press to delete ──────────────────────────────────────────────────
|
||||||
|
const onPointerDown = useCallback((e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
const t = setTimeout(() => setDeleting(true), 500)
|
||||||
|
setHoldTimer(t)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const onPointerUp = useCallback(() => {
|
||||||
|
if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) }
|
||||||
|
if (!deleting) onClick(slotIdx)
|
||||||
|
}, [holdTimer, deleting, onClick, slotIdx])
|
||||||
|
|
||||||
|
const onPointerLeave = useCallback(() => {
|
||||||
|
if (holdTimer) { clearTimeout(holdTimer); setHoldTimer(null) }
|
||||||
|
}, [holdTimer])
|
||||||
|
|
||||||
|
const confirmDelete = useCallback(() => {
|
||||||
|
setDeleting(false)
|
||||||
|
onDelete(slotIdx)
|
||||||
|
}, [onDelete, slotIdx])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center gap-1 select-none relative">
|
||||||
|
|
||||||
|
{/* Delete confirmation overlay (long-press) */}
|
||||||
|
{deleting && (
|
||||||
|
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-xl bg-black/90 border border-red-500">
|
||||||
|
<button
|
||||||
|
className="text-[10px] font-bold text-red-400 px-2 py-0.5 rounded bg-red-900/50 hover:bg-red-900"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="text-[10px] text-gray-500 hover:text-gray-300"
|
||||||
|
onClick={() => setDeleting(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick clear button — visible on non-empty slots */}
|
||||||
|
{status !== 'empty' && !deleting && (
|
||||||
|
<button
|
||||||
|
className="absolute -top-1.5 -right-1.5 z-10 w-4 h-4 rounded-full bg-gray-800 border border-border text-gray-500 hover:bg-red-900/60 hover:text-red-400 hover:border-red-700 text-[9px] leading-none flex items-center justify-center transition-colors"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete(slotIdx) }}
|
||||||
|
title="Clear slot"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main button */}
|
||||||
|
<button
|
||||||
|
className={`relative w-[76px] h-[54px] rounded-xl border-2 overflow-hidden flex flex-col items-center justify-center gap-0.5 transition-colors cursor-pointer ${style.bg} ${style.border} ${status === 'recording' ? 'animate-pulse' : ''}`}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerLeave={onPointerLeave}
|
||||||
|
>
|
||||||
|
<span className={`text-lg leading-none ${style.iconColor}`}>
|
||||||
|
{style.icon}
|
||||||
|
</span>
|
||||||
|
<span className={`text-[9px] font-bold uppercase tracking-widest leading-none ${style.iconColor} opacity-70`}>
|
||||||
|
{status === 'recording'
|
||||||
|
? `${(recordingDuration ?? 0).toFixed(1)}s`
|
||||||
|
: `Loop ${slotIdx + 1}`}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-border">
|
||||||
|
<div
|
||||||
|
ref={progressRef}
|
||||||
|
className="h-full bg-accent"
|
||||||
|
style={{ width: '0%', transition: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Controls row (playing/muted only) */}
|
||||||
|
{isActive && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{/* Volume toggle */}
|
||||||
|
<button
|
||||||
|
className={`text-[11px] transition-colors ${showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-400'}`}
|
||||||
|
onClick={() => setShowVol(v => !v)}
|
||||||
|
title="Volume"
|
||||||
|
>
|
||||||
|
🔊
|
||||||
|
</button>
|
||||||
|
{showVol && (
|
||||||
|
<input
|
||||||
|
type="range" min={0} max={1} step={0.05}
|
||||||
|
value={volume}
|
||||||
|
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
|
||||||
|
className="w-12 h-1 cursor-pointer accent-purple-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Re-trim button — only when original recording exists */}
|
||||||
|
{originalBuffer && (
|
||||||
|
<button
|
||||||
|
className="text-[11px] text-gray-600 hover:text-amber-400 transition-colors"
|
||||||
|
onClick={() => onRetrim(slotIdx)}
|
||||||
|
title="Re-trim this loop"
|
||||||
|
>
|
||||||
|
✂
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status label */}
|
||||||
|
<span className={`text-[9px] uppercase tracking-wider leading-none ${style.iconColor} opacity-50`}>
|
||||||
|
{LABEL[status] ?? ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,508 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import LoopTrimmer from './LoopTrimmer'
|
||||||
|
|
||||||
|
const H_TRACK = 56 // track canvas height px
|
||||||
|
const H_MASTER = 26 // master timeline height px
|
||||||
|
|
||||||
|
// ── Canvas draw helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function drawGrid(canvas, totalSec, bpm) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
if (!rect.width || !rect.height) return
|
||||||
|
const dpr = window.devicePixelRatio ?? 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
const W = rect.width, H = rect.height
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0f0f0f'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
if (!bpm || !totalSec) return
|
||||||
|
|
||||||
|
const beatSec = 60 / bpm
|
||||||
|
const barSec = beatSec * 4
|
||||||
|
|
||||||
|
// Beat lines
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let t = beatSec; t < totalSec; t += beatSec) {
|
||||||
|
if ((t % barSec) < beatSec * 0.4) continue
|
||||||
|
const x = (t / totalSec) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
}
|
||||||
|
// Bar lines + numbers
|
||||||
|
for (let t = 0; t <= totalSec; t += barSec) {
|
||||||
|
ctx.strokeStyle = 'rgba(168,85,247,0.45)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
const x = (t / totalSec) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
const n = Math.round(t / barSec)
|
||||||
|
if (n > 0) {
|
||||||
|
ctx.fillStyle = 'rgba(168,85,247,0.55)'
|
||||||
|
ctx.font = '9px monospace'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText(String(n), x + 3, H - 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWaveform(canvas, waveform, muted) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
if (!rect.width || !rect.height) return
|
||||||
|
const dpr = window.devicePixelRatio ?? 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
const W = rect.width, H = rect.height
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0f0f0f'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
const N = waveform.length
|
||||||
|
const mid = H / 2
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const x = (i / N) * W
|
||||||
|
const barW = Math.max(1, W / N - 0.3)
|
||||||
|
ctx.fillStyle = muted ? '#3b1f55' : '#a855f7'
|
||||||
|
const h = waveform[i] * mid * 0.85
|
||||||
|
ctx.fillRect(x, mid - h, barW, h * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRecording(canvas, duration, bpm) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
if (!rect.width || !rect.height) return
|
||||||
|
const dpr = window.devicePixelRatio ?? 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
const W = rect.width, H = rect.height
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0f0f0f'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
const beatSec = bpm ? 60 / bpm : null
|
||||||
|
const barSec = beatSec ? beatSec * 4 : null
|
||||||
|
const viewDur = barSec
|
||||||
|
? Math.max(barSec * 4, Math.ceil(duration / barSec + 1) * barSec)
|
||||||
|
: Math.max(8, duration * 1.4)
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
if (beatSec) {
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.06)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let t = beatSec; t < viewDur; t += beatSec) {
|
||||||
|
if (barSec && (t % barSec) < beatSec * 0.4) continue
|
||||||
|
const x = (t / viewDur) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
}
|
||||||
|
if (barSec) {
|
||||||
|
for (let t = barSec; t <= viewDur; t += barSec) {
|
||||||
|
ctx.strokeStyle = 'rgba(239,68,68,0.3)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
const x = (t / viewDur) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
const n = Math.round(t / barSec)
|
||||||
|
ctx.fillStyle = 'rgba(239,68,68,0.45)'
|
||||||
|
ctx.font = '9px monospace'
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillText(String(n), x + 2, H - 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Growing fill + cursor
|
||||||
|
const fillX = (duration / viewDur) * W
|
||||||
|
ctx.fillStyle = 'rgba(239,68,68,0.18)'
|
||||||
|
ctx.fillRect(0, 0, fillX, H)
|
||||||
|
ctx.strokeStyle = 'rgba(239,68,68,0.85)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.beginPath(); ctx.moveTo(fillX, 0); ctx.lineTo(fillX, H); ctx.stroke()
|
||||||
|
|
||||||
|
// Counter label
|
||||||
|
const bars = barSec ? Math.floor(duration / barSec) + 1 : null
|
||||||
|
const label = bars !== null
|
||||||
|
? `● REC BAR ${bars} ${duration.toFixed(1)}s — tap to stop`
|
||||||
|
: `● REC ${duration.toFixed(1)}s — tap to stop`
|
||||||
|
ctx.fillStyle = 'rgba(239,68,68,0.9)'
|
||||||
|
ctx.font = 'bold 11px monospace'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
ctx.fillText(label, W / 2, H / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Master Timeline ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function MasterTimeline({ masterStartRef, masterLenRef, audioCtxRef, bpm, masterLen }) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const playheadRef = useRef(null)
|
||||||
|
const rafRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const id = requestAnimationFrame(() => drawGrid(canvas, masterLen, bpm))
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [masterLen, bpm])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!masterLen) { cancelAnimationFrame(rafRef.current); return }
|
||||||
|
function tick() {
|
||||||
|
const ac = audioCtxRef.current
|
||||||
|
const t0 = masterStartRef.current
|
||||||
|
const len = masterLenRef.current
|
||||||
|
if (ac && t0 !== null && len && playheadRef.current) {
|
||||||
|
const pos = ((ac.currentTime - t0) % len) / len
|
||||||
|
playheadRef.current.style.left = `${pos * 100}%`
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(rafRef.current)
|
||||||
|
}, [masterLen, audioCtxRef, masterStartRef, masterLenRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative mx-4 mb-3 rounded overflow-hidden bg-surface border border-border"
|
||||||
|
style={{ height: `${H_MASTER}px` }}>
|
||||||
|
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||||
|
{!masterLen && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-[10px] text-gray-700">
|
||||||
|
record first loop to set master length
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{masterLen && (
|
||||||
|
<div
|
||||||
|
ref={playheadRef}
|
||||||
|
className="absolute top-0 bottom-0 w-px bg-white/50 pointer-events-none"
|
||||||
|
style={{ left: '0%' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Track Row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TrackRow({ slot, slotIdx, bpm, audioCtxRef, masterStartRef, masterLenRef,
|
||||||
|
onSlotClick, onRetrim, onDelete, onVolumeChange }) {
|
||||||
|
const [showVol, setShowVol] = useState(false)
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const playheadRef = useRef(null)
|
||||||
|
const rafRef = useRef(null)
|
||||||
|
|
||||||
|
const DOT_CLASS = {
|
||||||
|
empty: 'bg-gray-700',
|
||||||
|
recording: 'bg-red-500 animate-pulse',
|
||||||
|
trimming: 'bg-amber-500',
|
||||||
|
playing: 'bg-accent',
|
||||||
|
muted: 'bg-gray-500',
|
||||||
|
}
|
||||||
|
const BORDER_CLASS = {
|
||||||
|
empty: 'border-border',
|
||||||
|
recording: 'border-red-800',
|
||||||
|
trimming: 'border-amber-800/60',
|
||||||
|
playing: 'border-accent/40',
|
||||||
|
muted: 'border-border',
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotClass = DOT_CLASS[slot.status] ?? 'bg-gray-700'
|
||||||
|
const borderClass = BORDER_CLASS[slot.status] ?? 'border-border'
|
||||||
|
|
||||||
|
// Draw waveform when data arrives or mute state changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slot.waveform) return
|
||||||
|
if (slot.status === 'recording' || slot.status === 'trimming') return
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const id = requestAnimationFrame(() =>
|
||||||
|
drawWaveform(canvas, slot.waveform, slot.status === 'muted')
|
||||||
|
)
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [slot.waveform, slot.status])
|
||||||
|
|
||||||
|
// Draw recording progress on each duration tick
|
||||||
|
useEffect(() => {
|
||||||
|
if (slot.status !== 'recording') return
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
drawRecording(canvas, slot.recordingDuration, bpm)
|
||||||
|
}, [slot.recordingDuration, slot.status, bpm])
|
||||||
|
|
||||||
|
// Playhead animation
|
||||||
|
useEffect(() => {
|
||||||
|
const active = slot.status === 'playing' || slot.status === 'muted'
|
||||||
|
if (!active) {
|
||||||
|
cancelAnimationFrame(rafRef.current)
|
||||||
|
if (playheadRef.current) playheadRef.current.style.left = '-2px'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
function tick() {
|
||||||
|
const ac = audioCtxRef.current
|
||||||
|
const t0 = masterStartRef.current
|
||||||
|
const len = masterLenRef.current
|
||||||
|
if (ac && t0 !== null && len && playheadRef.current) {
|
||||||
|
const pos = ((ac.currentTime - t0) % len) / len
|
||||||
|
playheadRef.current.style.left = `${pos * 100}%`
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
rafRef.current = requestAnimationFrame(tick)
|
||||||
|
return () => cancelAnimationFrame(rafRef.current)
|
||||||
|
}, [slot.status, audioCtxRef, masterStartRef, masterLenRef])
|
||||||
|
|
||||||
|
const isClickable = slot.status !== 'trimming'
|
||||||
|
const isActive = slot.status === 'playing' || slot.status === 'muted'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-stretch border rounded-lg mb-1.5 overflow-hidden transition-colors ${borderClass}`}>
|
||||||
|
|
||||||
|
{/* Left: tap button (state dot + track number) */}
|
||||||
|
<button
|
||||||
|
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||||
|
disabled={!isClickable}
|
||||||
|
title={
|
||||||
|
slot.status === 'empty' ? 'Tap to record' :
|
||||||
|
slot.status === 'recording' ? 'Tap to stop' :
|
||||||
|
slot.status === 'playing' ? 'Tap to mute' :
|
||||||
|
slot.status === 'muted' ? 'Tap to unmute' : ''
|
||||||
|
}
|
||||||
|
className="flex flex-col items-center justify-center gap-1 px-3 bg-surface border-r border-border shrink-0 hover:bg-white/5 transition-colors disabled:cursor-default"
|
||||||
|
style={{ width: '44px' }}
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||||
|
<span className="text-[10px] text-gray-600 font-mono">{slotIdx + 1}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Canvas: waveform / recording / empty */}
|
||||||
|
<div
|
||||||
|
className="relative flex-1 cursor-pointer"
|
||||||
|
style={{ height: `${H_TRACK}px` }}
|
||||||
|
onClick={() => isClickable && onSlotClick(slotIdx)}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className="w-full h-full block" />
|
||||||
|
|
||||||
|
{slot.status === 'empty' && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||||
|
<span className="text-xs text-gray-700">tap to record</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{slot.status === 'trimming' && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-amber-950/20 pointer-events-none">
|
||||||
|
<span className="text-xs text-amber-500/60">trimming ↓</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Moving playhead */}
|
||||||
|
{isActive && (
|
||||||
|
<div
|
||||||
|
ref={playheadRef}
|
||||||
|
className="absolute top-0 bottom-0 w-px bg-white/40 pointer-events-none"
|
||||||
|
style={{ left: '-2px' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: controls */}
|
||||||
|
<div className="flex items-center gap-1 px-2 bg-surface border-l border-border shrink-0">
|
||||||
|
{showVol && (
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0} max={1} step={0.01}
|
||||||
|
value={slot.volume}
|
||||||
|
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
|
||||||
|
className="w-14 accent-purple-500"
|
||||||
|
title="Volume"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowVol(v => !v)}
|
||||||
|
className={`w-7 h-7 flex items-center justify-center rounded text-base transition-colors ${
|
||||||
|
showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-300'
|
||||||
|
}`}
|
||||||
|
title="Volume"
|
||||||
|
>
|
||||||
|
{slot.status === 'muted' ? '🔇' : '🔊'}
|
||||||
|
</button>
|
||||||
|
{isActive && slot.originalBuffer && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRetrim(slotIdx)}
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded text-gray-600 hover:text-amber-400 transition-colors text-sm"
|
||||||
|
title="Re-trim loop"
|
||||||
|
>
|
||||||
|
✂
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(slotIdx)}
|
||||||
|
className="w-7 h-7 flex items-center justify-center rounded text-gray-700 hover:text-red-400 transition-colors text-sm"
|
||||||
|
title="Clear track"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function LoopStation({
|
||||||
|
slots,
|
||||||
|
bpm,
|
||||||
|
masterLen,
|
||||||
|
audioCtxRef,
|
||||||
|
masterStartRef,
|
||||||
|
masterLenRef,
|
||||||
|
onSlotClick,
|
||||||
|
onCommitTrim,
|
||||||
|
onCancelRecord,
|
||||||
|
onRetrim,
|
||||||
|
onDelete,
|
||||||
|
onVolumeChange,
|
||||||
|
onAddSlot,
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [gridBpm, setGridBpm] = useState(bpm ?? '')
|
||||||
|
|
||||||
|
// Pre-fill BPM when detection arrives
|
||||||
|
useEffect(() => {
|
||||||
|
if (bpm && !gridBpm) setGridBpm(bpm)
|
||||||
|
}, [bpm]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const gridBpmNum = parseFloat(gridBpm) || null
|
||||||
|
const trimmingIdx = slots.findIndex(s => s.status === 'trimming')
|
||||||
|
|
||||||
|
const recordingCount = slots.filter(s => s.status === 'recording').length
|
||||||
|
const playingCount = slots.filter(s => s.status === 'playing').length
|
||||||
|
|
||||||
|
const dotClass = recordingCount > 0
|
||||||
|
? 'bg-red-500 animate-pulse'
|
||||||
|
: playingCount > 0
|
||||||
|
? 'bg-accent'
|
||||||
|
: 'bg-gray-700'
|
||||||
|
|
||||||
|
const masterLabel = (() => {
|
||||||
|
if (!masterLen) return null
|
||||||
|
if (gridBpmNum) {
|
||||||
|
const bars = Math.round(masterLen / ((60 / gridBpmNum) * 4))
|
||||||
|
return `${bars} bar${bars !== 1 ? 's' : ''} · ${masterLen.toFixed(2)}s`
|
||||||
|
}
|
||||||
|
return masterLen.toFixed(2) + 's'
|
||||||
|
})()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
{/* ── Header ──────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||||
|
<span>LOOP STATION</span>
|
||||||
|
{masterLabel && (
|
||||||
|
<span className="text-[11px] text-gray-600 font-mono">{masterLabel}</span>
|
||||||
|
)}
|
||||||
|
{recordingCount > 0 && (
|
||||||
|
<span className="text-[11px] text-red-400">● rec</span>
|
||||||
|
)}
|
||||||
|
{playingCount > 0 && recordingCount === 0 && (
|
||||||
|
<span className="text-[11px] text-accent">
|
||||||
|
{playingCount} loop{playingCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-1 bg-surface border border-border rounded-lg px-2 py-1">
|
||||||
|
<span className="text-[10px] text-gray-600 uppercase tracking-wider">BPM</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={40} max={300} step={1}
|
||||||
|
value={gridBpm}
|
||||||
|
onChange={e => setGridBpm(e.target.value)}
|
||||||
|
placeholder={bpm ? String(Math.round(bpm)) : '—'}
|
||||||
|
className="w-10 bg-transparent text-xs text-gray-300 text-center focus:outline-none focus:text-white"
|
||||||
|
style={{ MozAppearance: 'textfield' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||||
|
title={open ? 'Collapse' : 'Expand'}
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"
|
||||||
|
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{open
|
||||||
|
? <polyline points="2,8 6,4 10,8" />
|
||||||
|
: <polyline points="2,4 6,8 10,4" />
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ────────────────────────────────────────────────────────────── */}
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border pt-3">
|
||||||
|
|
||||||
|
{/* Master timeline */}
|
||||||
|
<MasterTimeline
|
||||||
|
masterStartRef={masterStartRef}
|
||||||
|
masterLenRef={masterLenRef}
|
||||||
|
audioCtxRef={audioCtxRef}
|
||||||
|
bpm={gridBpmNum}
|
||||||
|
masterLen={masterLen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Track rows */}
|
||||||
|
<div className="px-4">
|
||||||
|
{slots.map((slot, i) => (
|
||||||
|
<TrackRow
|
||||||
|
key={i}
|
||||||
|
slot={slot}
|
||||||
|
slotIdx={i}
|
||||||
|
bpm={gridBpmNum}
|
||||||
|
audioCtxRef={audioCtxRef}
|
||||||
|
masterStartRef={masterStartRef}
|
||||||
|
masterLenRef={masterLenRef}
|
||||||
|
onSlotClick={onSlotClick}
|
||||||
|
onRetrim={onRetrim}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onVolumeChange={onVolumeChange}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add track */}
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<button
|
||||||
|
onClick={onAddSlot}
|
||||||
|
className="w-full py-1.5 rounded-lg border border-dashed border-border text-gray-700 hover:border-accent/40 hover:text-accent/60 transition-colors text-xs flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">+</span>
|
||||||
|
<span>Add Track</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* LoopTrimmer — shown below tracks when trimming */}
|
||||||
|
{trimmingIdx !== -1 && (
|
||||||
|
<LoopTrimmer
|
||||||
|
slot={slots[trimmingIdx]}
|
||||||
|
slotIdx={trimmingIdx}
|
||||||
|
bpm={gridBpmNum}
|
||||||
|
audioCtxRef={audioCtxRef}
|
||||||
|
onCommit={onCommitTrim}
|
||||||
|
onCancel={onCancelRecord}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { useRef, useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
function fmtMs(sec) {
|
||||||
|
return `${(sec * 1000).toFixed(0)}ms`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSec(sec) {
|
||||||
|
return sec < 10 ? `${sec.toFixed(2)}s` : `${sec.toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoopTrimmer({ slot, slotIdx, bpm, audioCtxRef, onCommit, onCancel }) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const previewRef = useRef(null) // AudioBufferSourceNode for preview
|
||||||
|
|
||||||
|
const [trimStart, setTrimStart] = useState(slot.trimStart)
|
||||||
|
const [trimEnd, setTrimEnd] = useState(slot.trimEnd)
|
||||||
|
const [previewing, setPreviewing] = useState(false)
|
||||||
|
|
||||||
|
// Refs so drag closures always have current values
|
||||||
|
const trimStartRef = useRef(trimStart)
|
||||||
|
const trimEndRef = useRef(trimEnd)
|
||||||
|
useEffect(() => { trimStartRef.current = trimStart }, [trimStart])
|
||||||
|
useEffect(() => { trimEndRef.current = trimEnd }, [trimEnd])
|
||||||
|
|
||||||
|
const duration = slot.audioBuffer?.duration ?? 0
|
||||||
|
const startSec = trimStart * duration
|
||||||
|
const endSec = trimEnd * duration
|
||||||
|
const selectedSec = endSec - startSec
|
||||||
|
|
||||||
|
// Beat grid info — visual reference only, no snapping
|
||||||
|
const beatSec = bpm ? 60 / bpm : null
|
||||||
|
const barSec = beatSec ? beatSec * 4 : null
|
||||||
|
|
||||||
|
// Stop preview when handles change
|
||||||
|
useEffect(() => {
|
||||||
|
if (previewing) stopPreview()
|
||||||
|
}, [trimStart, trimEnd]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => stopPreview()
|
||||||
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
function stopPreview() {
|
||||||
|
try { previewRef.current?.stop() } catch {}
|
||||||
|
previewRef.current = null
|
||||||
|
setPreviewing(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePreview() {
|
||||||
|
if (previewing) { stopPreview(); return }
|
||||||
|
const ctx = audioCtxRef?.current
|
||||||
|
const buf = slot.audioBuffer
|
||||||
|
if (!ctx || !buf) return
|
||||||
|
|
||||||
|
// Resume context if suspended
|
||||||
|
if (ctx.state === 'suspended') ctx.resume().catch(() => {})
|
||||||
|
|
||||||
|
const sr = buf.sampleRate
|
||||||
|
const startSample = Math.floor(trimStartRef.current * buf.length)
|
||||||
|
const endSample = Math.ceil(trimEndRef.current * buf.length)
|
||||||
|
const len = Math.max(1, endSample - startSample)
|
||||||
|
const data = buf.getChannelData(0).slice(startSample, endSample)
|
||||||
|
|
||||||
|
const previewBuf = ctx.createBuffer(1, len, sr)
|
||||||
|
previewBuf.copyToChannel(data, 0)
|
||||||
|
|
||||||
|
const node = ctx.createBufferSource()
|
||||||
|
node.buffer = previewBuf
|
||||||
|
node.loop = true
|
||||||
|
node.loopStart = 0
|
||||||
|
node.loopEnd = len / sr
|
||||||
|
node.connect(ctx.destination)
|
||||||
|
node.start()
|
||||||
|
node.onended = () => { previewRef.current = null; setPreviewing(false) }
|
||||||
|
|
||||||
|
previewRef.current = node
|
||||||
|
setPreviewing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap end handle to N bars from current start
|
||||||
|
function snapBars(n) {
|
||||||
|
if (!barSec || !duration) return
|
||||||
|
const newEnd = Math.min(1, trimStartRef.current + (n * barSec) / duration)
|
||||||
|
setTrimEnd(newEnd)
|
||||||
|
trimEndRef.current = newEnd
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Canvas draw ─────────────────────────────────────────────────────────────
|
||||||
|
const draw = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas || !slot.waveform) return
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
if (rect.width === 0) return
|
||||||
|
const dpr = window.devicePixelRatio ?? 1
|
||||||
|
canvas.width = rect.width * dpr
|
||||||
|
canvas.height = rect.height * dpr
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
ctx.scale(dpr, dpr)
|
||||||
|
const W = rect.width
|
||||||
|
const H = rect.height
|
||||||
|
const wf = slot.waveform
|
||||||
|
const N = wf.length
|
||||||
|
const ts = trimStartRef.current
|
||||||
|
const te = trimEndRef.current
|
||||||
|
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = '#0f0f0f'
|
||||||
|
ctx.fillRect(0, 0, W, H)
|
||||||
|
|
||||||
|
// Dim regions outside selection
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.6)'
|
||||||
|
ctx.fillRect(0, 0, ts * W, H)
|
||||||
|
ctx.fillRect(te * W, 0, W - te * W, H)
|
||||||
|
|
||||||
|
// Beat grid — visual only, beat lines then bar lines (bars on top)
|
||||||
|
if (beatSec && duration) {
|
||||||
|
// Beat lines
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.10)'
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
for (let t = 0; t <= duration; t += beatSec) {
|
||||||
|
const isBar = barSec ? (t % barSec) < beatSec * 0.4 : false
|
||||||
|
if (!isBar) {
|
||||||
|
const x = (t / duration) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bar lines (brighter, thicker)
|
||||||
|
if (barSec) {
|
||||||
|
ctx.strokeStyle = 'rgba(168,85,247,0.55)'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
for (let t = 0; t <= duration; t += barSec) {
|
||||||
|
const x = (t / duration) * W
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke()
|
||||||
|
// Bar number label
|
||||||
|
const barNum = Math.round(t / barSec)
|
||||||
|
if (barNum > 0) {
|
||||||
|
ctx.fillStyle = 'rgba(168,85,247,0.5)'
|
||||||
|
ctx.font = `${9 * dpr / dpr}px monospace`
|
||||||
|
ctx.fillText(`${barNum}`, x + 3, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Waveform bars
|
||||||
|
const mid = H / 2
|
||||||
|
for (let i = 0; i < N; i++) {
|
||||||
|
const x = (i / N) * W
|
||||||
|
const barW = Math.max(1, W / N - 0.5)
|
||||||
|
const inSel = (i / N) >= ts && (i / N) <= te
|
||||||
|
ctx.fillStyle = inSel ? '#a855f7' : '#3b0764'
|
||||||
|
const h = wf[i] * mid * 0.88
|
||||||
|
ctx.fillRect(x, mid - h, barW, h * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle lines
|
||||||
|
ctx.strokeStyle = '#a855f7'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath(); ctx.moveTo(ts * W, 0); ctx.lineTo(ts * W, H); ctx.stroke()
|
||||||
|
ctx.beginPath(); ctx.moveTo(te * W, 0); ctx.lineTo(te * W, H); ctx.stroke()
|
||||||
|
}, [slot.waveform, beatSec, barSec, duration])
|
||||||
|
|
||||||
|
useEffect(() => { draw() }, [draw, trimStart, trimEnd])
|
||||||
|
useEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => draw())
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [draw])
|
||||||
|
|
||||||
|
// ── Pointer → fraction ──────────────────────────────────────────────────────
|
||||||
|
function fracFromClientX(clientX) {
|
||||||
|
const el = containerRef.current
|
||||||
|
if (!el) return 0
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
return Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Drag handles — free movement, no snapping ───────────────────────────────
|
||||||
|
function handleMouseDown(handle) {
|
||||||
|
return (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
function onMove(ev) {
|
||||||
|
const raw = fracFromClientX(ev.clientX)
|
||||||
|
if (handle === 'start') {
|
||||||
|
const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01))
|
||||||
|
setTrimStart(c); trimStartRef.current = c
|
||||||
|
} else {
|
||||||
|
const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw))
|
||||||
|
setTrimEnd(c); trimEndRef.current = c
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
function onUp() {
|
||||||
|
window.removeEventListener('mousemove', onMove)
|
||||||
|
window.removeEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', onMove)
|
||||||
|
window.addEventListener('mouseup', onUp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click canvas to move nearest handle
|
||||||
|
function handleCanvasClick(e) {
|
||||||
|
if (e.target !== canvasRef.current) return
|
||||||
|
const raw = fracFromClientX(e.clientX)
|
||||||
|
if (Math.abs(raw - trimStart) <= Math.abs(raw - trimEnd)) {
|
||||||
|
const c = Math.max(0, Math.min(raw, trimEndRef.current - 0.01))
|
||||||
|
setTrimStart(c); trimStartRef.current = c
|
||||||
|
} else {
|
||||||
|
const c = Math.max(trimStartRef.current + 0.01, Math.min(1, raw))
|
||||||
|
setTrimEnd(c); trimEndRef.current = c
|
||||||
|
}
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
{/* Header row */}
|
||||||
|
<div className="flex items-center justify-between px-4 pt-3 pb-1">
|
||||||
|
<span className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||||
|
Trim — Loop {slotIdx + 1}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500 font-mono">
|
||||||
|
<span className="text-gray-600">{fmtMs(startSec)} → {fmtMs(endSec)}</span>
|
||||||
|
<span className="text-accent font-semibold">{fmtSec(selectedSec)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Waveform + handles */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="relative mx-4 h-20 rounded-lg overflow-visible cursor-crosshair select-none"
|
||||||
|
onClick={handleCanvasClick}
|
||||||
|
>
|
||||||
|
<canvas ref={canvasRef} className="w-full h-full block rounded-lg" />
|
||||||
|
|
||||||
|
{/* Left handle */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||||
|
style={{ left: `${trimStart * 100}%` }}
|
||||||
|
onMouseDown={handleMouseDown('start')}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||||
|
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||||
|
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||||
|
{fmtMs(startSec)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right handle */}
|
||||||
|
<div
|
||||||
|
className="absolute top-0 bottom-0 w-5 -translate-x-1/2 cursor-ew-resize flex items-center justify-center group z-10"
|
||||||
|
style={{ left: `${trimEnd * 100}%` }}
|
||||||
|
onMouseDown={handleMouseDown('end')}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="w-0.5 h-full bg-accent/70 group-hover:bg-accent group-hover:w-1 transition-all" />
|
||||||
|
<div className="absolute w-3.5 h-3.5 rounded-full bg-accent border-2 border-white/20 shadow-lg top-1/2 -translate-y-1/2" />
|
||||||
|
<div className="absolute bottom-full mb-1 text-[9px] font-mono text-accent bg-panel border border-border rounded px-1 py-0.5 whitespace-nowrap pointer-events-none">
|
||||||
|
{fmtMs(endSec)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar: preview + bar snap + hint */}
|
||||||
|
<div className="flex items-center gap-2 px-4 pt-2 pb-1 flex-wrap">
|
||||||
|
{/* Preview play/stop */}
|
||||||
|
<button
|
||||||
|
onClick={togglePreview}
|
||||||
|
className={`flex items-center gap-1 px-2.5 py-1 rounded-lg border text-xs font-medium transition-all ${
|
||||||
|
previewing
|
||||||
|
? 'bg-accent/20 border-accent text-accent'
|
||||||
|
: 'bg-surface border-border text-gray-400 hover:text-white hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
title="Preview loop selection"
|
||||||
|
>
|
||||||
|
{previewing
|
||||||
|
? <><span>⏹</span><span>Stop</span></>
|
||||||
|
: <><span>▶</span><span>Preview</span></>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bar snap buttons — only if BPM is set */}
|
||||||
|
{barSec && duration && (
|
||||||
|
<div className="flex items-center gap-1 ml-1">
|
||||||
|
<span className="text-[10px] text-gray-600 uppercase tracking-wider mr-0.5">snap end →</span>
|
||||||
|
{[1, 2, 4].map(n => {
|
||||||
|
const endFrac = trimStart + (n * barSec) / duration
|
||||||
|
const fits = endFrac <= 1.02
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => snapBars(n)}
|
||||||
|
disabled={!fits}
|
||||||
|
className="px-2 py-0.5 rounded border border-border text-[10px] text-gray-400 hover:text-accent hover:border-accent/50 transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title={`Set end to ${n} bar${n > 1 ? 's' : ''} from start`}
|
||||||
|
>
|
||||||
|
{n} bar{n > 1 ? 's' : ''}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="ml-auto text-[10px] text-gray-700">
|
||||||
|
{bpm ? `${bpm} BPM grid` : 'no BPM — trim freely'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex gap-2 px-4 pb-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { stopPreview(); onCommit(slotIdx, trimStart, trimEnd) }}
|
||||||
|
className="px-5 py-1.5 bg-accent text-white text-sm font-bold rounded-lg hover:bg-accent/80 transition-colors"
|
||||||
|
>
|
||||||
|
Set Loop ▶
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { stopPreview(); onCommit(slotIdx, 0, 1) }}
|
||||||
|
className="px-4 py-1.5 bg-surface border border-border text-gray-400 text-sm rounded-lg hover:text-white hover:border-gray-500 transition-colors"
|
||||||
|
title="Use the full recording without trimming"
|
||||||
|
>
|
||||||
|
Use Full
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { stopPreview(); onCancel(slotIdx) }}
|
||||||
|
className="px-4 py-1.5 bg-surface border border-border text-gray-500 text-sm rounded-lg hover:text-red-400 hover:border-red-800 transition-colors ml-auto"
|
||||||
|
>
|
||||||
|
Re-record
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
|
const MODEL = 'claude-sonnet-4-6'
|
||||||
|
const API_URL = 'https://api.anthropic.com/v1/messages'
|
||||||
|
const LS_KEY = 'wtf_teacher_key'
|
||||||
|
|
||||||
|
// ── System prompt — rebuilt with live session context on every request ────────
|
||||||
|
function buildSystemPrompt({ keyInfo, currentChord, bpm, chordHistory }) {
|
||||||
|
const keyStr = keyInfo ? `${keyInfo.root} ${keyInfo.mode}` : 'not detected yet'
|
||||||
|
const chordStr = currentChord?.name ?? 'none detected'
|
||||||
|
const bpmStr = bpm ? `${Math.round(bpm)} BPM` : 'not detected'
|
||||||
|
const histStr = chordHistory?.length
|
||||||
|
? chordHistory.map(c => c.name).join(' → ')
|
||||||
|
: 'none yet'
|
||||||
|
|
||||||
|
return `You are an expert music teacher and session musician embedded in JamBuddy, a real-time chord and key detection app for guitarists and keyboard players at live jam sessions.
|
||||||
|
|
||||||
|
LIVE SESSION CONTEXT (updated in real time):
|
||||||
|
• Detected key: ${keyStr}
|
||||||
|
• Current chord: ${chordStr}
|
||||||
|
• BPM: ${bpmStr}
|
||||||
|
• Recent chord history: ${histStr}
|
||||||
|
|
||||||
|
YOUR ROLE:
|
||||||
|
- Explain chords, scales, and music theory in plain, friendly language
|
||||||
|
- Suggest what to practice based on the current key and chord progression
|
||||||
|
- Teach playing techniques: fretting, strumming patterns, chord voicings, fingerpicking
|
||||||
|
- Help musicians understand WHY things sound the way they do
|
||||||
|
- Suggest progressions that work with whatever the user is currently playing
|
||||||
|
- Adjust depth to the user — explain basics if they seem new, go deep if they ask for it
|
||||||
|
- Point out interesting connections: "that Dm7 works here because it's the ii chord in C major"
|
||||||
|
|
||||||
|
STYLE:
|
||||||
|
- Keep responses focused and practical — this is a live jam, not a classroom
|
||||||
|
- Use plain text, not markdown. Short paragraphs. Bullet points with "-" are fine.
|
||||||
|
- If someone asks about the current chord or key, use the live context above
|
||||||
|
- Max ~150 words unless someone asks for a deep dive`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick-action chips ────────────────────────────────────────────────────────
|
||||||
|
const CHIPS = [
|
||||||
|
{ label: 'What should I practice?', msg: 'Based on what I\'m playing right now, what\'s the most useful thing I could practice?' },
|
||||||
|
{ label: 'Explain current chord', msg: 'Explain the current chord I\'m playing — what it is, why it sounds the way it does, and where it tends to appear.' },
|
||||||
|
{ label: 'Scales that work here', msg: 'What scales work over the current key and chord? Which notes sound best to improvise with?' },
|
||||||
|
{ label: 'Suggest a progression', msg: 'Suggest a chord progression that fits the current key. Give me something interesting to try.' },
|
||||||
|
{ label: 'Technique tip', msg: 'Give me one technique tip — something I can work on in the next few minutes to sound better.' },
|
||||||
|
{ label: 'Why does this sound good?', msg: 'Looking at my recent chord history, why do these chords sound good together? What\'s the music theory behind it?' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// ── Simple text renderer (bold + line breaks) ─────────────────────────────────
|
||||||
|
function MessageText({ text }) {
|
||||||
|
const lines = text.split('\n')
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
if (!line.trim()) return <div key={i} className="h-1" />
|
||||||
|
// Bold: **text**
|
||||||
|
const parts = line.split(/(\*\*[^*]+\*\*)/)
|
||||||
|
return (
|
||||||
|
<p key={i} className="leading-relaxed">
|
||||||
|
{parts.map((part, j) =>
|
||||||
|
part.startsWith('**') && part.endsWith('**')
|
||||||
|
? <strong key={j} className="text-white font-semibold">{part.slice(2, -2)}</strong>
|
||||||
|
: part
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
export default function MusicTeacher({ keyInfo, currentChord, bpm, chordHistory }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [apiKey, setApiKey] = useState(() => localStorage.getItem(LS_KEY) ?? '')
|
||||||
|
const [showKeyInput, setShowKeyInput] = useState(false)
|
||||||
|
const [messages, setMessages] = useState([]) // [{role, content}]
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [streaming, setStreaming] = useState('') // partial response being streamed
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
const scrollRef = useRef(null)
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
const abortRef = useRef(null)
|
||||||
|
|
||||||
|
// Always scroll to bottom on new content
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
}
|
||||||
|
}, [messages, streaming])
|
||||||
|
|
||||||
|
// Focus input when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && apiKey && inputRef.current) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50)
|
||||||
|
}
|
||||||
|
}, [open, apiKey])
|
||||||
|
|
||||||
|
function saveKey(k) {
|
||||||
|
setApiKey(k)
|
||||||
|
localStorage.setItem(LS_KEY, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearKey() {
|
||||||
|
setApiKey('')
|
||||||
|
localStorage.removeItem(LS_KEY)
|
||||||
|
setShowKeyInput(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendMessage = useCallback(async (userText) => {
|
||||||
|
if (!userText.trim() || loading || !apiKey) return
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
const userMsg = { role: 'user', content: userText.trim() }
|
||||||
|
const nextMessages = [...messages, userMsg]
|
||||||
|
setMessages(nextMessages)
|
||||||
|
setInput('')
|
||||||
|
setLoading(true)
|
||||||
|
setStreaming('')
|
||||||
|
|
||||||
|
const context = { keyInfo, currentChord, bpm, chordHistory }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
abortRef.current = ctrl
|
||||||
|
|
||||||
|
const res = await fetch(API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
signal: ctrl.signal,
|
||||||
|
headers: {
|
||||||
|
'x-api-key': apiKey,
|
||||||
|
'anthropic-version': '2023-06-01',
|
||||||
|
'anthropic-dangerous-direct-browser-access': 'true',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: MODEL,
|
||||||
|
max_tokens: 1024,
|
||||||
|
stream: true,
|
||||||
|
system: buildSystemPrompt(context),
|
||||||
|
messages: nextMessages,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}))
|
||||||
|
throw new Error(body?.error?.message ?? `API error ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let full = ''
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
|
for (const line of chunk.split('\n')) {
|
||||||
|
if (!line.startsWith('data: ')) continue
|
||||||
|
const data = line.slice(6).trim()
|
||||||
|
if (data === '[DONE]' || !data) continue
|
||||||
|
try {
|
||||||
|
const ev = JSON.parse(data)
|
||||||
|
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
|
||||||
|
full += ev.delta.text
|
||||||
|
setStreaming(full)
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: full }])
|
||||||
|
setStreaming('')
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'AbortError') {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
abortRef.current = null
|
||||||
|
}
|
||||||
|
}, [messages, loading, apiKey, keyInfo, currentChord, bpm, chordHistory])
|
||||||
|
|
||||||
|
function stopGeneration() {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
if (streaming) {
|
||||||
|
setMessages(prev => [...prev, { role: 'assistant', content: streaming }])
|
||||||
|
setStreaming('')
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
sendMessage(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasKey = apiKey.trim().length > 0
|
||||||
|
|
||||||
|
// Dot: purple when API key set, gray otherwise
|
||||||
|
const dotClass = hasKey ? 'bg-accent' : 'bg-gray-700'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
|
||||||
|
{/* ── Header ─────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
|
||||||
|
<span>MUSIC TEACHER</span>
|
||||||
|
<span className="text-[11px] text-gray-600">AI · Claude</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Key indicator */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKeyInput(v => !v)}
|
||||||
|
className="text-[10px] text-gray-600 hover:text-gray-400 transition-colors px-1.5 py-0.5 rounded border border-transparent hover:border-border"
|
||||||
|
title={hasKey ? 'API key set — click to change' : 'Set API key'}
|
||||||
|
>
|
||||||
|
{hasKey ? '🔑 key set' : '🔑 add key'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(v => !v)}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{open
|
||||||
|
? <polyline points="2,8 6,4 10,8" />
|
||||||
|
: <polyline points="2,4 6,8 10,4" />
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Body ───────────────────────────────────────────────────────────── */}
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-border">
|
||||||
|
|
||||||
|
{/* API key input (shown when no key or user wants to change) */}
|
||||||
|
{(!hasKey || showKeyInput) && (
|
||||||
|
<div className="px-4 py-3 bg-surface/50 border-b border-border">
|
||||||
|
<p className="text-xs text-gray-500 mb-2">
|
||||||
|
Enter your <a className="text-accent underline" href="https://console.anthropic.com/keys" target="_blank" rel="noreferrer">Anthropic API key</a> to enable the music teacher. Stored locally on your device only.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={e => setApiKey(e.target.value)}
|
||||||
|
placeholder="sk-ant-..."
|
||||||
|
className="flex-1 px-2.5 py-1.5 bg-surface border border-border rounded-lg text-xs text-gray-300 focus:outline-none focus:border-accent font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => { saveKey(apiKey); setShowKeyInput(false) }}
|
||||||
|
disabled={!apiKey.trim()}
|
||||||
|
className="px-3 py-1.5 bg-accent text-white text-xs font-bold rounded-lg hover:bg-accent/80 transition-colors disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
{hasKey && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKeyInput(false)}
|
||||||
|
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasKey && (
|
||||||
|
<>
|
||||||
|
{/* Live session context strip */}
|
||||||
|
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-[10px] font-mono">
|
||||||
|
<span className="text-gray-600 uppercase tracking-wider">Now:</span>
|
||||||
|
{keyInfo ? (
|
||||||
|
<span className="text-accent">{keyInfo.root} {keyInfo.mode}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700">no key</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-800">·</span>
|
||||||
|
{currentChord ? (
|
||||||
|
<span className="text-white">{currentChord.name}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700">no chord</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-800">·</span>
|
||||||
|
<span className="text-gray-500">{bpm ? `${Math.round(bpm)} bpm` : '— bpm'}</span>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setMessages([]); setError(null) }}
|
||||||
|
className="ml-auto text-gray-700 hover:text-gray-400 transition-colors"
|
||||||
|
title="Clear conversation"
|
||||||
|
>
|
||||||
|
clear chat
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chat messages */}
|
||||||
|
{messages.length > 0 || streaming ? (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="max-h-72 overflow-y-auto px-4 py-3 space-y-3 text-xs"
|
||||||
|
>
|
||||||
|
{messages.map((m, i) => (
|
||||||
|
<div key={i} className={m.role === 'user' ? 'flex justify-end' : ''}>
|
||||||
|
{m.role === 'user' ? (
|
||||||
|
<div className="max-w-[80%] bg-accent/20 border border-accent/30 rounded-xl rounded-tr-sm px-3 py-2 text-gray-200">
|
||||||
|
{m.content}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-gray-300 leading-relaxed">
|
||||||
|
<MessageText text={m.content} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{streaming && (
|
||||||
|
<div className="text-gray-300 text-xs leading-relaxed">
|
||||||
|
<MessageText text={streaming} />
|
||||||
|
<span className="inline-block w-1.5 h-3.5 bg-accent/70 animate-pulse ml-0.5 align-middle" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-400 text-xs bg-red-950/30 border border-red-900/50 rounded-lg px-3 py-2">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Quick-action chips (shown when no chat history yet) */
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<p className="text-[10px] text-gray-700 mb-2 uppercase tracking-wider">Ask something</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{CHIPS.map(chip => (
|
||||||
|
<button
|
||||||
|
key={chip.label}
|
||||||
|
onClick={() => sendMessage(chip.msg)}
|
||||||
|
className="px-2.5 py-1 bg-surface border border-border rounded-full text-[10px] text-gray-400 hover:text-white hover:border-accent/50 hover:bg-accent/10 transition-all"
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input row */}
|
||||||
|
<div className="px-4 py-3 border-t border-border flex gap-2 items-end">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={e => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Ask anything about music theory, technique, or what to play…"
|
||||||
|
rows={1}
|
||||||
|
className="flex-1 px-3 py-2 bg-surface border border-border rounded-xl text-xs text-gray-300 placeholder-gray-700 focus:outline-none focus:border-accent resize-none leading-relaxed"
|
||||||
|
style={{ maxHeight: '80px', overflowY: 'auto' }}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<button
|
||||||
|
onClick={stopGeneration}
|
||||||
|
className="px-3 py-2 bg-surface border border-border text-gray-500 hover:text-red-400 hover:border-red-800 text-xs rounded-xl transition-colors shrink-0"
|
||||||
|
title="Stop"
|
||||||
|
>
|
||||||
|
⏹
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => sendMessage(input)}
|
||||||
|
disabled={!input.trim()}
|
||||||
|
className="px-3 py-2 bg-accent text-white text-xs font-bold rounded-xl hover:bg-accent/80 transition-colors disabled:opacity-40 shrink-0"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick chips after first message */}
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<div className="px-4 pb-3 flex flex-wrap gap-1.5">
|
||||||
|
{CHIPS.slice(0, 4).map(chip => (
|
||||||
|
<button
|
||||||
|
key={chip.label}
|
||||||
|
onClick={() => sendMessage(chip.msg)}
|
||||||
|
disabled={loading}
|
||||||
|
className="px-2 py-0.5 bg-surface border border-border rounded-full text-[9px] text-gray-600 hover:text-gray-300 hover:border-accent/30 transition-all disabled:opacity-30"
|
||||||
|
>
|
||||||
|
{chip.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -434,3 +434,440 @@ export function getChordSubstitutions(chordName) {
|
|||||||
chord: NOTES[p.rootPc] + (CHORD_TYPES[sub.type]?.suffix ?? ''),
|
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 1–4, 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: R–3–5–high 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 1–4)', 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: C–E–G–B)',
|
||||||
|
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 3–4 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: A–C–E–G)',
|
||||||
|
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: B–D–F–Ab — 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 (b3–b5–b7)', 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: 'ii–V–i 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 ii–V–i)',
|
||||||
|
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 ii–V–i 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: A–B–E — 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: C–E–G–A, 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 ii–V–I 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 3–4)', use: 'Keep ring and pinky on strings 1–2 (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 3–4 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 3–4 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.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user