loop station

This commit is contained in:
vadimwit
2026-03-20 01:03:43 +00:00
parent 8cd9b01941
commit f583979b3e
9 changed files with 2600 additions and 25 deletions
+410
View File
@@ -0,0 +1,410 @@
import { useState, useRef, useEffect, useCallback } from 'react'
const MODEL = 'claude-sonnet-4-6'
const API_URL = 'https://api.anthropic.com/v1/messages'
const LS_KEY = 'wtf_teacher_key'
// ── System prompt — rebuilt with live session context on every request ────────
function buildSystemPrompt({ keyInfo, currentChord, bpm, chordHistory }) {
const keyStr = keyInfo ? `${keyInfo.root} ${keyInfo.mode}` : 'not detected yet'
const chordStr = currentChord?.name ?? 'none detected'
const bpmStr = bpm ? `${Math.round(bpm)} BPM` : 'not detected'
const histStr = chordHistory?.length
? chordHistory.map(c => c.name).join(' → ')
: 'none yet'
return `You are an expert music teacher and session musician embedded in JamBuddy, a real-time chord and key detection app for guitarists and keyboard players at live jam sessions.
LIVE SESSION CONTEXT (updated in real time):
• Detected key: ${keyStr}
• Current chord: ${chordStr}
• BPM: ${bpmStr}
• Recent chord history: ${histStr}
YOUR ROLE:
- Explain chords, scales, and music theory in plain, friendly language
- Suggest what to practice based on the current key and chord progression
- Teach playing techniques: fretting, strumming patterns, chord voicings, fingerpicking
- Help musicians understand WHY things sound the way they do
- Suggest progressions that work with whatever the user is currently playing
- Adjust depth to the user — explain basics if they seem new, go deep if they ask for it
- Point out interesting connections: "that Dm7 works here because it's the ii chord in C major"
STYLE:
- Keep responses focused and practical — this is a live jam, not a classroom
- Use plain text, not markdown. Short paragraphs. Bullet points with "-" are fine.
- If someone asks about the current chord or key, use the live context above
- Max ~150 words unless someone asks for a deep dive`
}
// ── Quick-action chips ────────────────────────────────────────────────────────
const CHIPS = [
{ label: 'What should I practice?', msg: 'Based on what I\'m playing right now, what\'s the most useful thing I could practice?' },
{ label: 'Explain current chord', msg: 'Explain the current chord I\'m playing — what it is, why it sounds the way it does, and where it tends to appear.' },
{ label: 'Scales that work here', msg: 'What scales work over the current key and chord? Which notes sound best to improvise with?' },
{ label: 'Suggest a progression', msg: 'Suggest a chord progression that fits the current key. Give me something interesting to try.' },
{ label: 'Technique tip', msg: 'Give me one technique tip — something I can work on in the next few minutes to sound better.' },
{ label: 'Why does this sound good?', msg: 'Looking at my recent chord history, why do these chords sound good together? What\'s the music theory behind it?' },
]
// ── Simple text renderer (bold + line breaks) ─────────────────────────────────
function MessageText({ text }) {
const lines = text.split('\n')
return (
<div className="space-y-1">
{lines.map((line, i) => {
if (!line.trim()) return <div key={i} className="h-1" />
// Bold: **text**
const parts = line.split(/(\*\*[^*]+\*\*)/)
return (
<p key={i} className="leading-relaxed">
{parts.map((part, j) =>
part.startsWith('**') && part.endsWith('**')
? <strong key={j} className="text-white font-semibold">{part.slice(2, -2)}</strong>
: part
)}
</p>
)
})}
</div>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export default function MusicTeacher({ keyInfo, currentChord, bpm, chordHistory }) {
const [open, setOpen] = useState(false)
const [apiKey, setApiKey] = useState(() => localStorage.getItem(LS_KEY) ?? '')
const [showKeyInput, setShowKeyInput] = useState(false)
const [messages, setMessages] = useState([]) // [{role, content}]
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('') // partial response being streamed
const [error, setError] = useState(null)
const scrollRef = useRef(null)
const inputRef = useRef(null)
const abortRef = useRef(null)
// Always scroll to bottom on new content
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
}
}, [messages, streaming])
// Focus input when panel opens
useEffect(() => {
if (open && apiKey && inputRef.current) {
setTimeout(() => inputRef.current?.focus(), 50)
}
}, [open, apiKey])
function saveKey(k) {
setApiKey(k)
localStorage.setItem(LS_KEY, k)
}
function clearKey() {
setApiKey('')
localStorage.removeItem(LS_KEY)
setShowKeyInput(true)
}
const sendMessage = useCallback(async (userText) => {
if (!userText.trim() || loading || !apiKey) return
setError(null)
const userMsg = { role: 'user', content: userText.trim() }
const nextMessages = [...messages, userMsg]
setMessages(nextMessages)
setInput('')
setLoading(true)
setStreaming('')
const context = { keyInfo, currentChord, bpm, chordHistory }
try {
const ctrl = new AbortController()
abortRef.current = ctrl
const res = await fetch(API_URL, {
method: 'POST',
signal: ctrl.signal,
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true',
'content-type': 'application/json',
},
body: JSON.stringify({
model: MODEL,
max_tokens: 1024,
stream: true,
system: buildSystemPrompt(context),
messages: nextMessages,
}),
})
if (!res.ok) {
const body = await res.json().catch(() => ({}))
throw new Error(body?.error?.message ?? `API error ${res.status}`)
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let full = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
for (const line of chunk.split('\n')) {
if (!line.startsWith('data: ')) continue
const data = line.slice(6).trim()
if (data === '[DONE]' || !data) continue
try {
const ev = JSON.parse(data)
if (ev.type === 'content_block_delta' && ev.delta?.type === 'text_delta') {
full += ev.delta.text
setStreaming(full)
}
} catch {}
}
}
setMessages(prev => [...prev, { role: 'assistant', content: full }])
setStreaming('')
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message)
}
} finally {
setLoading(false)
abortRef.current = null
}
}, [messages, loading, apiKey, keyInfo, currentChord, bpm, chordHistory])
function stopGeneration() {
abortRef.current?.abort()
if (streaming) {
setMessages(prev => [...prev, { role: 'assistant', content: streaming }])
setStreaming('')
}
setLoading(false)
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
sendMessage(input)
}
}
const hasKey = apiKey.trim().length > 0
// Dot: purple when API key set, gray otherwise
const dotClass = hasKey ? 'bg-accent' : 'bg-gray-700'
return (
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
{/* ── Header ─────────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
<button
onClick={() => setOpen(v => !v)}
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
>
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
<span>MUSIC TEACHER</span>
<span className="text-[11px] text-gray-600">AI · Claude</span>
</button>
<div className="flex items-center gap-2">
{/* Key indicator */}
<button
onClick={() => setShowKeyInput(v => !v)}
className="text-[10px] text-gray-600 hover:text-gray-400 transition-colors px-1.5 py-0.5 rounded border border-transparent hover:border-border"
title={hasKey ? 'API key set — click to change' : 'Set API key'}
>
{hasKey ? '🔑 key set' : '🔑 add key'}
</button>
<button
onClick={() => setOpen(v => !v)}
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
{open
? <polyline points="2,8 6,4 10,8" />
: <polyline points="2,4 6,8 10,4" />
}
</svg>
</button>
</div>
</div>
{/* ── Body ───────────────────────────────────────────────────────────── */}
{open && (
<div className="border-t border-border">
{/* API key input (shown when no key or user wants to change) */}
{(!hasKey || showKeyInput) && (
<div className="px-4 py-3 bg-surface/50 border-b border-border">
<p className="text-xs text-gray-500 mb-2">
Enter your <a className="text-accent underline" href="https://console.anthropic.com/keys" target="_blank" rel="noreferrer">Anthropic API key</a> to enable the music teacher. Stored locally on your device only.
</p>
<div className="flex gap-2">
<input
type="password"
value={apiKey}
onChange={e => setApiKey(e.target.value)}
placeholder="sk-ant-..."
className="flex-1 px-2.5 py-1.5 bg-surface border border-border rounded-lg text-xs text-gray-300 focus:outline-none focus:border-accent font-mono"
/>
<button
onClick={() => { saveKey(apiKey); setShowKeyInput(false) }}
disabled={!apiKey.trim()}
className="px-3 py-1.5 bg-accent text-white text-xs font-bold rounded-lg hover:bg-accent/80 transition-colors disabled:opacity-40"
>
Save
</button>
{hasKey && (
<button
onClick={() => setShowKeyInput(false)}
className="px-3 py-1.5 text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
Cancel
</button>
)}
</div>
</div>
)}
{hasKey && (
<>
{/* Live session context strip */}
<div className="flex items-center gap-3 px-4 py-2 border-b border-border text-[10px] font-mono">
<span className="text-gray-600 uppercase tracking-wider">Now:</span>
{keyInfo ? (
<span className="text-accent">{keyInfo.root} {keyInfo.mode}</span>
) : (
<span className="text-gray-700">no key</span>
)}
<span className="text-gray-800">·</span>
{currentChord ? (
<span className="text-white">{currentChord.name}</span>
) : (
<span className="text-gray-700">no chord</span>
)}
<span className="text-gray-800">·</span>
<span className="text-gray-500">{bpm ? `${Math.round(bpm)} bpm` : '— bpm'}</span>
{messages.length > 0 && (
<button
onClick={() => { setMessages([]); setError(null) }}
className="ml-auto text-gray-700 hover:text-gray-400 transition-colors"
title="Clear conversation"
>
clear chat
</button>
)}
</div>
{/* Chat messages */}
{messages.length > 0 || streaming ? (
<div
ref={scrollRef}
className="max-h-72 overflow-y-auto px-4 py-3 space-y-3 text-xs"
>
{messages.map((m, i) => (
<div key={i} className={m.role === 'user' ? 'flex justify-end' : ''}>
{m.role === 'user' ? (
<div className="max-w-[80%] bg-accent/20 border border-accent/30 rounded-xl rounded-tr-sm px-3 py-2 text-gray-200">
{m.content}
</div>
) : (
<div className="text-gray-300 leading-relaxed">
<MessageText text={m.content} />
</div>
)}
</div>
))}
{streaming && (
<div className="text-gray-300 text-xs leading-relaxed">
<MessageText text={streaming} />
<span className="inline-block w-1.5 h-3.5 bg-accent/70 animate-pulse ml-0.5 align-middle" />
</div>
)}
{error && (
<div className="text-red-400 text-xs bg-red-950/30 border border-red-900/50 rounded-lg px-3 py-2">
{error}
</div>
)}
</div>
) : (
/* Quick-action chips (shown when no chat history yet) */
<div className="px-4 py-3">
<p className="text-[10px] text-gray-700 mb-2 uppercase tracking-wider">Ask something</p>
<div className="flex flex-wrap gap-1.5">
{CHIPS.map(chip => (
<button
key={chip.label}
onClick={() => sendMessage(chip.msg)}
className="px-2.5 py-1 bg-surface border border-border rounded-full text-[10px] text-gray-400 hover:text-white hover:border-accent/50 hover:bg-accent/10 transition-all"
>
{chip.label}
</button>
))}
</div>
</div>
)}
{/* Input row */}
<div className="px-4 py-3 border-t border-border flex gap-2 items-end">
<textarea
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything about music theory, technique, or what to play…"
rows={1}
className="flex-1 px-3 py-2 bg-surface border border-border rounded-xl text-xs text-gray-300 placeholder-gray-700 focus:outline-none focus:border-accent resize-none leading-relaxed"
style={{ maxHeight: '80px', overflowY: 'auto' }}
/>
{loading ? (
<button
onClick={stopGeneration}
className="px-3 py-2 bg-surface border border-border text-gray-500 hover:text-red-400 hover:border-red-800 text-xs rounded-xl transition-colors shrink-0"
title="Stop"
>
</button>
) : (
<button
onClick={() => sendMessage(input)}
disabled={!input.trim()}
className="px-3 py-2 bg-accent text-white text-xs font-bold rounded-xl hover:bg-accent/80 transition-colors disabled:opacity-40 shrink-0"
>
Send
</button>
)}
</div>
{/* Quick chips after first message */}
{messages.length > 0 && (
<div className="px-4 pb-3 flex flex-wrap gap-1.5">
{CHIPS.slice(0, 4).map(chip => (
<button
key={chip.label}
onClick={() => sendMessage(chip.msg)}
disabled={loading}
className="px-2 py-0.5 bg-surface border border-border rounded-full text-[9px] text-gray-600 hover:text-gray-300 hover:border-accent/30 transition-all disabled:opacity-30"
>
{chip.label}
</button>
))}
</div>
)}
</>
)}
</div>
)}
</div>
)
}