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

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

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

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

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

Ask something

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