diff --git a/src/App.css b/src/App.css index 82fef4c..8f1af59 100644 --- a/src/App.css +++ b/src/App.css @@ -240,4 +240,59 @@ .animate-pop { animation: pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards; +} + +/* Switch Button for Metronome */ +.switch-button { + width: 44px; + height: 24px; + border-radius: 99px; + background-color: #ddd; + position: relative; + cursor: pointer; + transition: background-color 0.2s; + padding: 0; + border: none; + display: flex; + align-items: center; +} + +.switch-button.active { + background-color: var(--color-primary); +} + +.switch-thumb { + width: 18px; + height: 18px; + background-color: white; + border-radius: 50%; + position: absolute; + left: 3px; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.switch-button.active .switch-thumb { + transform: translateX(17px); +} + +/* Hint Button styling - similar to skip button but distinct */ +.hint-button { + background-color: transparent; + border: 2px solid var(--color-primary); + color: var(--color-primary); + padding: 0.5rem 1rem; + border-radius: 30px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; + font-size: 0.9rem; +} + +.hint-button:hover { + background-color: rgba(var(--hue-primary), 0.1); + transform: translateY(-2px); } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 11ce3f2..458338b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { SheetMusic } from './components/SheetMusic'; import { Controls, type AppSettings } from './components/Controls'; import { usePitchDetector } from './hooks/usePitchDetector'; +import { useMetronome } from './hooks/useMetronome'; import { getRandomNote, getNoteDetails @@ -11,7 +12,7 @@ import { getFretboardPositions } from './music/Tunings'; import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; -import { Mic, MicOff, SkipForward } from 'lucide-react'; +import { Mic, MicOff, SkipForward, HelpCircle } from 'lucide-react'; import './App.css'; import './styles/skip-button.css'; @@ -23,14 +24,22 @@ function App() { const [targetMidi, setTargetMidi] = useState(60); // Start with C4 const [settings, setSettings] = useState({ - difficulty: 'first_pos', // Will be dynamic, but initial default needed + difficulty: 'first_pos', showHint: false, tuningId: 'standard', keySignature: 'C', - instrument: 'guitar' + instrument: 'guitar', + rhythm: { + mode: 'bpm', + bpm: 60, + seconds: 5, + active: false, + autoAdvance: false, + sound: true, + volume: 0.5 + } }); - const [streak, setStreak] = useState(0); const [matchStartTime, setMatchStartTime] = useState(null); const [feedbackMessage, setFeedbackMessage] = useState(""); @@ -78,6 +87,30 @@ function App() { }, [settings.difficulty, settings.tuningId]); + // Metronome Logic + // Calculate effective BPM based on mode + const effectiveBpm = useMemo(() => { + if (settings.rhythm.mode === 'bpm') return settings.rhythm.bpm; + // In seconds mode, BPM = 60 / seconds + return 60 / settings.rhythm.seconds; + }, [settings.rhythm.mode, settings.rhythm.bpm, settings.rhythm.seconds]); + + const handleTick = useCallback(() => { + if (settings.rhythm.autoAdvance && settings.rhythm.active) { + generateNewNote(); + // Reset feedback message on auto-tick + setFeedbackMessage(""); + } + }, [settings.rhythm.autoAdvance, settings.rhythm.active, generateNewNote]); + + const { restart: restartMetronome } = useMetronome({ + bpm: effectiveBpm, + volume: settings.rhythm.sound ? settings.rhythm.volume : 0, + playing: settings.rhythm.active, + onTick: handleTick + }); + + // Match Logic useEffect(() => { if (!pitchData) { @@ -91,23 +124,42 @@ function App() { } else { const duration = Date.now() - matchStartTime; if (duration > NOTE_MATCH_THRESHOLD_MS) { - setStreak(s => s + 1); + // Success! setFeedbackMessage("Good!"); - // Simple flash effect or delay - setTimeout(() => { - generateNewNote(); - }, 800); + const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance; + const isTimerMode = settings.rhythm.mode === 'seconds'; - setMatchStartTime(null); + if (!isRhythmActive) { + // Standard or Rhythm-Manual + + setTimeout(() => { + generateNewNote(); + }, 800); + setMatchStartTime(null); + } else { + // Rhythm Active AND Auto-Advance + if (isTimerMode) { + // Dynamic Timer Mode: Success triggers advance + + restartMetronome(); // Reset the countdown + setTimeout(() => { + generateNewNote(); + }, 200); + setMatchStartTime(null); + } else { + // Strict BPM Mode: Consumed success, but wait for tick. + if (feedbackMessage !== "Good!") { // Only increment if not already good + + } + } + } } } } else { setMatchStartTime(null); } - }, [pitchData, targetMidi, matchStartTime, generateNewNote]); - - + }, [pitchData, targetMidi, matchStartTime, generateNewNote, settings.rhythm, restartMetronome, feedbackMessage]); // Hint text construction const hintPositions = useMemo(() => { @@ -120,8 +172,7 @@ function App() { return (
-
Antigravity Guitar
-
Streak: {streak}
+
Sheet music trainer
@@ -161,7 +212,14 @@ function App() { {error &&
{error}
} -
+
+ + {/* Rhythm Controls */} +
+
+ + +
+ + {settings.rhythm.active && ( +
+
+ + +
+ + {settings.rhythm.mode === 'bpm' ? ( +
+ {settings.rhythm.bpm} BPM + updateRhythm({ bpm: Number(e.target.value) })} + style={{ flex: 1 }} + /> +
+ ) : ( +
+ {settings.rhythm.seconds}s + updateRhythm({ seconds: Number(e.target.value) })} + style={{ flex: 1 }} + /> +
+ )} + +
+ + + +
+
+ )} +
+ + +
); }; diff --git a/src/hooks/useMetronome.ts b/src/hooks/useMetronome.ts new file mode 100644 index 0000000..26c8457 --- /dev/null +++ b/src/hooks/useMetronome.ts @@ -0,0 +1,102 @@ +import { useRef, useEffect, useCallback } from 'react'; + +interface MetronomeOptions { + bpm: number; + volume: number; // 0.0 to 1.0 + playing: boolean; + onTick: (beat: number) => void; +} + +export interface MetronomeHandle { + restart: () => void; +} + +export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions): MetronomeHandle { + const audioContext = useRef(null); + const nextNoteTime = useRef(0.0); + const timerID = useRef(null); + const lookahead = 25.0; // How frequently to call scheduling function (in milliseconds) + const scheduleAheadTime = 0.1; // How far ahead to schedule audio (sec) + const currentBeat = useRef(0); + + // Keep callback fresh without re-triggering effect + const onTickRef = useRef(onTick); + useEffect(() => { + onTickRef.current = onTick; + }, [onTick]); + + const nextNote = useCallback(() => { + const secondsPerBeat = 60.0 / bpm; + nextNoteTime.current += secondsPerBeat; + currentBeat.current++; + }, [bpm]); + + const playClick = useCallback((time: number) => { + if (!audioContext.current) return; + + const osc = audioContext.current.createOscillator(); + const gainNode = audioContext.current.createGain(); + + osc.connect(gainNode); + gainNode.connect(audioContext.current.destination); + + // Click sound: high pitch, very short decay + osc.frequency.value = 880; + + // Simple envelope + gainNode.gain.setValueAtTime(volume, time); + gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.05); + + osc.start(time); + osc.stop(time + 0.05); + + onTickRef.current(currentBeat.current); + }, [volume]); + + const scheduler = useCallback(() => { + if (!audioContext.current) return; + + // while there are notes that will need to play before the next interval, + // schedule them and advance the pointer. + while (nextNoteTime.current < audioContext.current.currentTime + scheduleAheadTime) { + playClick(nextNoteTime.current); + nextNote(); + } + timerID.current = window.setTimeout(scheduler, lookahead); + }, [nextNote, playClick]); + + const restart = useCallback(() => { + if (audioContext.current) { + // Reset to play one beat interval from NOW + const secondsPerBeat = 60.0 / bpm; + nextNoteTime.current = audioContext.current.currentTime + secondsPerBeat; + // Should we reset currentBeat? + currentBeat.current = 0; + } + }, [bpm]); + + useEffect(() => { + if (playing) { + if (!audioContext.current) { + audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)(); + } + + // Resume if suspended (browser autoplay policy) + if (audioContext.current.state === 'suspended') { + audioContext.current.resume(); + } + + currentBeat.current = 0; + nextNoteTime.current = audioContext.current.currentTime + 0.05; + scheduler(); + + return () => { + if (timerID.current) window.clearTimeout(timerID.current); + }; + } else { + if (timerID.current) window.clearTimeout(timerID.current); + } + }, [playing, scheduler]); + + return { restart }; +}