import { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react'; import { Logo } from './components/Logo'; import { LandscapeSuggestion } from './components/LandscapeSuggestion'; import { Controls } from './components/Controls'; import { SettingsModal } from './components/SettingsModal'; import { OpenSourceModal } from './components/OpenSourceModal'; import { SingleNoteTrainer, type SingleNoteHandle } from './components/SingleNoteTrainer'; import { LiveNoteStaff } from './components/LiveNoteStaff'; import { resolveClefTranspose } from './music/InstrumentConfigs'; import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer'; import { AssistMode } from './components/AssistMode'; import { usePitchDetector } from './hooks/usePitchDetector'; import { useSettings } from './context/useSettings'; import { audioEngine } from './audio/AudioEngine'; import { midiToFrequency } from './music/NoteUtils'; import { TUNINGS } from './music/Tunings'; import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; import { Mic, MicOff, HelpCircle, X, Settings, Maximize, Minimize } from 'lucide-react'; import './App.css'; import './styles/skip-button.css'; function App() { const [listening, setListening] = useState(false); const { settings, updateSettings } = useSettings(); const setSettings = updateSettings; const isPhrase = settings.gameMode === 'phrase'; const isAssist = settings.gameMode === 'assist'; const currentTuning = TUNINGS[settings.tuningId]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; // Lowest note the instrument can produce: the pitch analysis window adapts // to it (shorter window = lower latency for instruments without deep bass) const instrumentMinMidi = useMemo(() => { let min = Infinity; for (const r of currentInstrumentDef.ranges) { if (r.min !== undefined) min = Math.min(min, r.min); } if (currentTuning) { for (const stringMidi of currentTuning.strings) min = Math.min(min, stringMidi); } return min === Infinity ? 40 : min; // Fallback: guitar low E }, [currentInstrumentDef, currentTuning]); // One semitone of margin below the lowest note (detuned strings, flat playing) const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector( listening, settings.micSensitivity, midiToFrequency(instrumentMinMidi - 1) ); // Live feedback shows only what the USER plays, gated while the app's own // speaker output is audible (see SingleNoteTrainer / PhraseTrainer). const displayedPitch = audioEngine.isAudible() ? null : pitchData; const singleNoteRef = useRef(null); const phraseRef = useRef(null); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [windowWidth, setWindowWidth] = useState(window.innerWidth); const [windowHeight, setWindowHeight] = useState(window.innerHeight); useEffect(() => { const handleResize = () => { setWindowWidth(window.innerWidth); setWindowHeight(window.innerHeight); }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // Theme support — MUST be a layout effect: children read the resolved theme // colors from getComputedStyle inside their own passive effects. useLayoutEffect(() => { const root = document.documentElement; const theme = settings.theme || 'auto'; if (theme === 'auto') { root.removeAttribute('data-theme'); } else { root.setAttribute('data-theme', theme); } }, [settings.theme]); 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); } }, []); /* Keyboard Shortcuts */ const [showHelp, setShowHelp] = useState(false); // Opening dialogs pauses a running phrase run (contract E1/lifecycle). const openSettings = useCallback(() => { phraseRef.current?.pause(); setIsSettingsOpen(true); }, []); const openHelp = useCallback(() => { phraseRef.current?.pause(); setShowHelp(true); }, []); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; // Text inputs / selects keep their native keys — shortcuts must not // fire while typing. FOCUSED BUTTONS however stay eligible: after a // click the button keeps focus, and gating there made N/R/P/L // silently dead until the user clicked elsewhere. Space/Enter on a // focused button activate it natively (guarded below), so no // double-fire. const onTextEntry = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement || !!target?.isContentEditable; if (onTextEntry) return; if (e.code === 'Escape') { if (showHelp) setShowHelp(false); else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false })); return; } // Prevent default for space to stop scrolling (Space on a focused // button activates it natively — preventDefault only applies when the // focus is on the body, where the shortcut below takes over). if (e.code === 'Space' && !(target instanceof HTMLButtonElement)) { e.preventDefault(); } if (isPhrase) { switch (e.key.toLowerCase()) { case 'h': setSettings(s => ({ ...s, showHint: !s.showHint })); break; case 'z': setSettings(s => ({ ...s, zenMode: !s.zenMode })); break; case 'r': phraseRef.current?.retry(); break; case 'p': phraseRef.current?.previewToggle(); break; case 'n': phraseRef.current?.next(); break; case 'arrowleft': phraseRef.current?.previewStep(-1); break; case 'arrowright': phraseRef.current?.previewStep(1); break; case 's': phraseRef.current?.skip(); break; case 'v': setSettings(s => ({ ...s, showFretboard: !s.showFretboard })); break; case 'l': setListening(l => !l); break; } // Space/Enter on a focused button natively activate that button // (e.g. Start); only treat them as pause-toggle when NOT on a button. if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) { phraseRef.current?.pauseToggle(); } return; } if (isAssist) { // Assist mode: only display shortcuts — no trainer actions. switch (e.key.toLowerCase()) { case 'h': setSettings(s => ({ ...s, showHint: !s.showHint })); break; case 'z': setSettings(s => ({ ...s, zenMode: !s.zenMode })); break; case 'v': setSettings(s => ({ ...s, showFretboard: !s.showFretboard })); break; case 'l': setListening(l => !l); break; } return; } 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 singleNoteRef.current?.playCurrent(); break; case 'm': // Cycle 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; } if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) { singleNoteRef.current?.skipNote(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [settings, showHelp, isPhrase, isAssist, setSettings]); // 'm' cycles back to sight reading from phrase/assist modes. useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key.toLowerCase() !== 'm') return; const target = e.target as HTMLElement | null; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || target?.isContentEditable) return; if (settings.gameMode === 'phrase' || settings.gameMode === 'assist') { setSettings(s => ({ ...s, gameMode: 'sight_reading' })); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [settings.gameMode, setSettings]); // Auto-enable mic when the tuner is turned on (rAF defers the state update // out of the effect phase to avoid cascading renders) useEffect(() => { if (settings.showTuningMeter && !listening) { const id = requestAnimationFrame(() => setListening(true)); return () => cancelAnimationFrame(id); } }, [settings.showTuningMeter, listening]); return (
{!settings.zenMode && (
)}
{isPhrase ? ( ) : isAssist ? ( ) : ( )} {error &&
{error}
} { !settings.zenMode && (
{/* Live staff omitted in Assist mode: the Assist card already shows the played note large above the fretboard. */} {!isAssist && ( )}
{displayedPitch ? ( <> {displayedPitch.note} {Math.round(displayedPitch.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

{isPhrase ? ( <>
Start / Pause / ResumeSpace
Preview phrase (Play / Stop)P
While previewing: step note forward / back→ / ←
Retry phraseR
New melody / next barsN
Skip note (at your pace)S
) : ( <>
Skip NoteSpace
Replay NoteR
Toggle Game ModeM
)}
Toggle HintH
Toggle Virtual InstrumentV
Toggle MicL
Toggle Zen ModeZ
Close Help / Exit ZenEsc
) } setIsSettingsOpen(false)} settings={settings} onUpdateSettings={setSettings} audioLevel={audioLevel} debugInfo={debugInfo} isListening={isListening} onMicToggle={() => setListening(l => !l)} /> setIsOpenSourceModalOpen(false)} />
); } export default App;