import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Logo } from './components/Logo'; import { LandscapeSuggestion } from './components/LandscapeSuggestion'; import { SheetMusic } from './components/SheetMusic'; import { Controls, type AppSettings } from './components/Controls'; import { SettingsModal } from './components/SettingsModal'; import { OpenSourceModal } from './components/OpenSourceModal'; import { usePitchDetector } from './hooks/usePitchDetector'; import { useMetronome } from './hooks/useMetronome'; import { useAudioPlayer } from './hooks/useAudioPlayer'; import { getRandomNote, getNoteDetails } from './music/NoteUtils'; import { TUNINGS, getFretboardPositions, getOpenStringNotes, getFirstPositionNotes, } from './music/Tunings'; import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; import { Fretboard } from './components/Fretboard'; import { PianoKeys } from './components/PianoKeys'; import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X, Guitar, Settings, Maximize, Minimize } from 'lucide-react'; import './App.css'; import './styles/skip-button.css'; const NOTE_MATCH_THRESHOLD_MS = 300; // How long to convert hold note to confirm function App() { const [listening, setListening] = useState(false); const { playNote } = useAudioPlayer(); const [targetMidi, setTargetMidi] = useState(60); // Start with C4 const [settings, setSettings] = useState({ difficulty: 'first_pos', showHint: false, showFretboard: false, showTuningMeter: false, tuningId: 'standard', keySignature: 'C', instrument: 'guitar', rhythm: { mode: 'bpm', bpm: 60, seconds: 5, active: false, autoAdvance: false, sound: true, volume: 0.5 }, zenMode: false, gameMode: 'sight_reading', customMinFret: 0, customMaxFret: 12, autoPlaySightReading: false, autoPlayVolume: 0.5, virtualGuitarVolume: 0.5, virtualGuitarMute: false, micSensitivity: 0.5, disableAnimation: false }); const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(listening, settings.micSensitivity); const [matchStartTime, setMatchStartTime] = useState(null); const [feedbackMessage, setFeedbackMessage] = useState(""); const [revealed, setRevealed] = useState(false); const [virtualNote, setVirtualNote] = useState(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false); const [hoveredMidi, setHoveredMidi] = useState(null); const feedbackTimeoutRef = useRef | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { const handleFullscreenChange = () => { setIsFullscreen(!!document.fullscreenElement); }; document.addEventListener('fullscreenchange', handleFullscreenChange); return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); }, []); const toggleFullscreen = useCallback(async () => { try { if (!document.fullscreenElement) { await document.documentElement.requestFullscreen(); } else { await document.exitFullscreen(); } } catch (err) { console.error("Error toggling fullscreen:", err); } }, []); const currentTuning = TUNINGS[settings.tuningId]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; // Resolve overrides const currentRangeDef = useMemo(() => { return currentInstrumentDef.ranges.find(r => r.id === settings.difficulty); }, [currentInstrumentDef, settings.difficulty]); const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode; const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose; // Generate valid notes based on difficulty const validNotes = useMemo(() => { // Find range config const rangeConfig = currentInstrumentDef.ranges.find(r => r.id === settings.difficulty); const getNotesFromConfig = (config: typeof rangeConfig) => { if (!config) return []; // Dynamic logic based on type if (config.type === 'open_strings') { // Use current tuning if applicable (Guitar/Bass) if (currentTuning) { return getOpenStringNotes(currentTuning); } // Fallback for non-fretted if they happen to use this type (unlikely) return config.notes || []; } if (config.type === 'first_position') { if (currentTuning) { return getFirstPositionNotes(currentTuning); } return config.notes || []; } if (config.type === 'custom_fret') { if (currentTuning) { const minFret = settings.customMinFret ?? config.defaultMinFret ?? 0; const maxFret = settings.customMaxFret ?? config.defaultMaxFret ?? 12; const notes = new Set(); currentTuning.strings.forEach(stringMidi => { for (let fret = minFret; fret <= maxFret; fret++) { notes.add(stringMidi + fret); } }); return Array.from(notes).sort((a, b) => a - b); } return []; } if (config.type === 'specific_string') { if (currentTuning && config.stringIndex !== undefined) { const openNote = currentTuning.strings[config.stringIndex]; if (openNote === undefined) return []; // Generate frets 0 to 12 for this string const notes = []; for (let i = 0; i <= 12; i++) { notes.push(openNote + i); } return notes; } return []; } // Static fallback if (config.notes) return config.notes; if (config.min !== undefined && config.max !== undefined) { return Array.from({ length: config.max - config.min + 1 }, (_, i) => config.min! + i); } return []; }; if (rangeConfig) { return getNotesFromConfig(rangeConfig); } // Fallback if difficulty ID doesn't match current instrument (e.g. after switch) // Return first range of current instrument const fallbackRange = currentInstrumentDef.ranges[0]; return getNotesFromConfig(fallbackRange); }, [settings.difficulty, currentInstrumentDef, currentTuning, settings.customMinFret, settings.customMaxFret]); const generateNewNote = useCallback((keepFeedback = false) => { // Determine min/max based on available notes to avoid infinite loops if validNotes empty if (validNotes.length === 0) return; const min = validNotes[0]; const max = validNotes[validNotes.length - 1]; const newNote = getRandomNote(min, max, validNotes); if (newNote === targetMidi && validNotes.length > 1) { // Try once to get a different note const retry = getRandomNote(min, max, validNotes); setTargetMidi(retry); } else { setTargetMidi(newNote); } setMatchStartTime(null); if (!keepFeedback) { setFeedbackMessage(""); } setRevealed(false); }, [validNotes, targetMidi]); // Audio Playback trigger useEffect(() => { const shouldAutoPlay = settings.gameMode === 'ear_training' || (settings.gameMode === 'sight_reading' && settings.autoPlaySightReading); if (shouldAutoPlay && !revealed) { // Add a small delay to ensure state settles or allow UI to update const timer = setTimeout(() => { playNote(targetMidi, 1.0, settings.autoPlayVolume ?? 0.5); // Play for 1 second }, 100); return () => clearTimeout(timer); } }, [targetMidi, settings.gameMode, settings.autoPlaySightReading, revealed, playNote]); // Initial note, and whenever difficulty/tuning/gamemode changes useEffect(() => { generateNewNote(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [settings.difficulty, settings.tuningId, settings.gameMode]); // 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 }); // Common success handler const handleMatchSuccess = useCallback(() => { // Success! if (feedbackTimeoutRef.current) { clearTimeout(feedbackTimeoutRef.current); } const noteDetails = getNoteDetails(targetMidi); setFeedbackMessage(`Good! ${noteDetails.name}`); setRevealed(true); setMatchStartTime(null); const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance; const isTimerMode = settings.rhythm.mode === 'seconds'; if (!isRhythmActive) { // Standard or Rhythm-Manual // Immediate transition generateNewNote(true); // Keep "Good!" message if (settings.disableAnimation) { setFeedbackMessage(""); setRevealed(false); } else { // Allow visual feedback to persist for a moment before clearing text feedbackTimeoutRef.current = setTimeout(() => { setFeedbackMessage(""); }, 1500); } } else { // Rhythm Active AND Auto-Advance if (isTimerMode) { // Dynamic Timer Mode: Success triggers advance restartMetronome(); // Reset the countdown // Immediate transition here too? generateNewNote(true); feedbackTimeoutRef.current = setTimeout(() => { setFeedbackMessage(""); }, 1500); setMatchStartTime(null); } else { // Strict BPM Mode: Consumed success, but wait for tick. // Logic handled by tick } } }, [targetMidi, settings.rhythm, settings.disableAnimation, restartMetronome, generateNewNote]); // Virtual Instrument Handler (Guitar or Piano) const handleVirtualInstrumentPlay = useCallback((playedMidi: number) => { if (!settings.virtualGuitarMute) { playNote(playedMidi, 0.5, settings.virtualGuitarVolume ?? 0.5); // Feedback sound } setVirtualNote(playedMidi); // Clear the note visualization after a short delay setTimeout(() => { setVirtualNote(null); }, 500); if (playedMidi === targetMidi) { // Instant match handleMatchSuccess(); } }, [playNote, targetMidi, feedbackMessage, handleMatchSuccess, settings]); // Match Logic useEffect(() => { if (!pitchData) { setMatchStartTime(null); return; } if (pitchData.midi === targetMidi) { if (matchStartTime === null) { setMatchStartTime(Date.now()); } else { const duration = Date.now() - matchStartTime; if (duration > NOTE_MATCH_THRESHOLD_MS) { handleMatchSuccess(); } } } else { setMatchStartTime(null); } }, [pitchData, targetMidi, matchStartTime, feedbackMessage, handleMatchSuccess]); /* Keyboard Shortcuts */ const [showHelp, setShowHelp] = useState(false); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Ignore if typing in an input if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; // prevent default for space to stop scrolling if (e.code === 'Space') { e.preventDefault(); } switch (e.key.toLowerCase()) { case 'h': setSettings(s => ({ ...s, showHint: !s.showHint })); break; case 'z': setSettings(s => ({ ...s, zenMode: !s.zenMode })); break; case 'r': // Replay case 'p': // Play playNote(targetMidi, 1.0); break; case 'm': // Toggle Game Mode setSettings(s => ({ ...s, gameMode: s.gameMode === 'sight_reading' ? 'ear_training' : 'sight_reading' })); break; case 'v': // Virtual Instrument setSettings(s => ({ ...s, showFretboard: !s.showFretboard })); break; case 'l': // Listening (Mic) setListening(l => !l); break; } // Check non-character keys via code to avoid layout issues for function keys if (e.code === 'Space' || e.code === 'Enter') { generateNewNote(); } if (e.code === 'Escape') { if (showHelp) setShowHelp(false); else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false })); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [settings.zenMode, showHelp, generateNewNote, playNote, targetMidi]); // Hint text construction const hintPositions = useMemo(() => { // If showing full hint OR detecting via fretboard, we need positions? // Actually, showHint=true renders the red dots. // If showFretboard=true but showHint=false, we render empty board (interactive). if (!settings.showHint && !settings.showFretboard) return []; if (!currentInstrumentDef.showTuning || !currentTuning) return []; // If hint is hidden but board is explicit, we still need data if we want to support 'hint-on-hover' or similar later. // But for now, if settings.showHint is FALSE, we pass EMPTY positions to Fretboard so it doesn't draw dots, // UNLESS we want to decouple 'positions' prop from 'showHint' prop in the component. // It's cleaner to pass the positions regardless and let the component decide based on a prop, // OR filter here. // The request says: "If hints are enabled simultaneously, they can be shown on the same fretboard" // So if showHint is true -> pass positions. If false -> pass empty. if (!settings.showHint) return []; return getFretboardPositions(targetMidi, currentTuning); }, [settings.showHint, settings.showFretboard, targetMidi, currentTuning, currentInstrumentDef]); // Auto-enable mic when tuner is turned on useEffect(() => { if (settings.showTuningMeter && !listening) { setListening(true); } }, [settings.showTuningMeter, listening]); return (
{!settings.zenMode && (
)}
{!settings.zenMode && (
{feedbackMessage ? (
{feedbackMessage.startsWith("Good! ") ? ( <>
Good!
{feedbackMessage.replace("Good! ", "")}
) : ( feedbackMessage )}
) : (
{settings.gameMode === 'ear_training' ? "Listen and play the note" : "Play the note above"}
)}
)} {(settings.showHint || settings.showFretboard) && (
{settings.showHint && (
{getNoteDetails(targetMidi + activeTranspose).scientific}
)} {/* Virtual Instrument Display */} {currentInstrumentDef.showTuning && ( currentInstrumentDef.id === 'piano' ? ( ) : ( currentTuning && ( ) ) )}
)} {error &&
{error}
} {!settings.zenMode && (
{currentInstrumentDef.showTuning && ( )}
)}
{ !settings.zenMode && (
{pitchData ? ( <> {pitchData.note} {Math.round(pitchData.frequency)} Hz ) : ( {listening ? "Listening..." : "Mic Off"} )}
) }
{!settings.zenMode && ( )} {/* Exit Zen Mode Button - Only shown in Zen Mode */} {settings.zenMode && ( )} {/* Help Popup */} { showHelp && (
setShowHelp(false)}>
e.stopPropagation()}>

Keyboard Shortcuts

Skip Note Space
Toggle Hint H
Toggle Virtual Instrument V
Toggle Mic L
Toggle Zen Mode Z
Replay Note R
Toggle Game Mode M
Close Help / Exit Zen Esc
) } setIsSettingsOpen(false)} settings={settings} onUpdateSettings={setSettings} audioLevel={audioLevel} debugInfo={debugInfo} isListening={isListening} /> setIsOpenSourceModalOpen(false)} />
); } export default App;