diff --git a/src/App.tsx b/src/App.tsx index deb5867..00af3c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,9 +15,9 @@ import { getFirstPositionNotes, } from './music/Tunings'; import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; -import { FretboardHint } from './components/FretboardHint'; -import { TuningMeter } from './components/TuningMeter'; -import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X } from 'lucide-react'; +import { Fretboard } from './components/Fretboard'; + +import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X, Guitar } from 'lucide-react'; import './App.css'; import './styles/skip-button.css'; @@ -32,6 +32,7 @@ function App() { const [settings, setSettings] = useState({ difficulty: 'first_pos', showHint: false, + showFretboard: false, showTuningMeter: false, tuningId: 'standard', keySignature: 'C', @@ -49,7 +50,8 @@ function App() { gameMode: 'sight_reading', customMinFret: 0, customMaxFret: 12, - autoPlaySightReading: false + autoPlaySightReading: false, + autoPlayVolume: 0.5 }); const [matchStartTime, setMatchStartTime] = useState(null); @@ -160,7 +162,7 @@ function App() { if (shouldAutoPlay && !revealed) { // Add a small delay to ensure state settles or allow UI to update const timer = setTimeout(() => { - playNote(targetMidi, 1.0); // Play for 1 second + playNote(targetMidi, 1.0, settings.autoPlayVolume ?? 0.5); // Play for 1 second }, 100); return () => clearTimeout(timer); } @@ -197,6 +199,49 @@ function App() { }); + // Common success handler + const handleMatchSuccess = useCallback(() => { + // Success! + setFeedbackMessage("Good!"); + setRevealed(true); + setMatchStartTime(null); + + const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance; + const isTimerMode = settings.rhythm.mode === 'seconds'; + + if (!isRhythmActive) { + // Standard or Rhythm-Manual + setTimeout(() => { + generateNewNote(); + }, 800); + } 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. + // Logic handled by tick + } + } + }, [settings.rhythm, restartMetronome, generateNewNote]); + + // Virtual Guitar Handler + const handleVirtualGuitarPlay = useCallback((playedMidi: number) => { + playNote(playedMidi, 0.5); // Feedback sound + + if (playedMidi === targetMidi) { + // Instant match + if (feedbackMessage !== "Good!") { + handleMatchSuccess(); + } + } + }, [playNote, targetMidi, feedbackMessage, handleMatchSuccess]); + // Match Logic useEffect(() => { if (!pitchData) { @@ -213,43 +258,13 @@ function App() { } else { const duration = Date.now() - matchStartTime; if (duration > NOTE_MATCH_THRESHOLD_MS) { - // Success! - setFeedbackMessage("Good!"); - setRevealed(true); - setMatchStartTime(null); - - const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance; - const isTimerMode = settings.rhythm.mode === 'seconds'; - - if (!isRhythmActive) { - // Standard or Rhythm-Manual - - setTimeout(() => { - generateNewNote(); - }, 800); - } 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 - - } - } - } + handleMatchSuccess(); } } } else { setMatchStartTime(null); } - }, [pitchData, targetMidi, matchStartTime, generateNewNote, settings.rhythm, restartMetronome, feedbackMessage]); + }, [pitchData, targetMidi, matchStartTime, feedbackMessage, handleMatchSuccess]); /* Keyboard Shortcuts */ const [showHelp, setShowHelp] = useState(false); @@ -300,11 +315,25 @@ function App() { // 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 []; - if (!currentInstrumentDef.showTuning || !currentTuning) return []; // No fretboard hints for piano/voice return getFretboardPositions(targetMidi, currentTuning); - }, [settings.showHint, targetMidi, currentTuning, currentInstrumentDef]); + }, [settings.showHint, settings.showFretboard, targetMidi, currentTuning, currentInstrumentDef]); // Auto-enable mic when tuner is turned on useEffect(() => { @@ -377,13 +406,21 @@ function App() { - {settings.showHint && ( + {(settings.showHint || settings.showFretboard) && (
-
- {getNoteDetails(targetMidi + currentInstrumentDef.transpose).scientific} -
+ {settings.showHint && ( +
+ {getNoteDetails(targetMidi + currentInstrumentDef.transpose).scientific} +
+ )} {currentInstrumentDef.showTuning && currentTuning && ( - + )}
)} @@ -401,6 +438,15 @@ function App() { {settings.showHint ? "Hide Hint" : "Show Hint"} + + - + {/* Tool 3: Auto-play */} +
+
+ +
- ) : ( -
/* Empty placeholder for grid 3rd column if not sight reading? Or just 2 cols? */ - )} + + {(settings.gameMode === 'ear_training' || settings.autoPlaySightReading) && ( +
+ + onUpdateSettings({ ...settings, autoPlayVolume: parseFloat(e.target.value) })} + style={{ flex: 1 }} + /> +
+ )} +
); diff --git a/src/components/FretboardHint.tsx b/src/components/Fretboard.tsx similarity index 59% rename from src/components/FretboardHint.tsx rename to src/components/Fretboard.tsx index 11a7f38..d78363c 100644 --- a/src/components/FretboardHint.tsx +++ b/src/components/Fretboard.tsx @@ -1,12 +1,22 @@ import type { Tuning, FretPosition } from '../music/Tunings'; -interface FretboardHintProps { +interface FretboardProps { tuning: Tuning; positions: FretPosition[]; - maxFrets?: number; // How many frets to display + maxFrets?: number; + interactive?: boolean; + showHints?: boolean; + onPlayNote?: (midi: number) => void; } -export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHintProps) { +export function Fretboard({ + tuning, + positions, + maxFrets = 15, + interactive = false, + showHints = true, + onPlayNote +}: FretboardProps) { // Config const numStrings = tuning.strings.length; // Visual params @@ -24,23 +34,18 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin const markers = [3, 5, 7, 9, 12, 15, 17, 19, 21].filter(m => m <= maxFrets); // Helper to get Y coordinate for a string index - // String 0 is lowest pitch. In tab/charts, lowest pitch is usually the BOTTOM line. - // So index 0 -> max Y. + // String 0 is lowest pitch (Bottom line) + // String N is highest pitch (Top line) const getStringY = (stringIndex: number) => { - // stringIndex 0 (Low E) -> bottom line - // stringIndex N (High E) -> top line return height - paddingY - (stringIndex * stringSpacing); }; // Helper to get X coordinate for a fret line - // Fret N line is at x = padding + nut + N * width const getFretLineX = (fretNum: number) => { return paddingX + nutWidth + (fretNum * fretWidth); }; // Helper to get Center X for a note at fret N - // If fret 0 (open), place it to left of nut. - // If fret > 0, place it in middle of Fret N-1 and Fret N. const getNoteX = (fretNum: number) => { if (fretNum === 0) { return paddingX + (nutWidth / 2) - 15; // To the left of nut @@ -50,13 +55,19 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin return (startX + endX) / 2; }; + const handleFretClick = (stringIndex: number, fret: number) => { + if (!interactive || !onPlayNote) return; + const baseMidi = tuning.strings[stringIndex]; + const noteMidi = baseMidi + fret; + onPlayNote(noteMidi); + }; + return ( -
+
{/* Fretboard background */} @@ -74,17 +85,15 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin y={paddingY} width={nutWidth} height={height - (paddingY * 2)} - fill="#333" // Dark nut + fill="#333" /> - {/* Fret Markers (Dots) on fretboard */} + {/* Fret Markers (Dots) */} {markers.map(m => { const cx = getNoteX(m); - const cy = height / 2; const isDouble = m % 12 === 0; if (isDouble) { - // Draw two dots return ( @@ -92,15 +101,14 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin ); } - return ( - + ); })} {/* Frets (Vertical lines) */} {Array.from({ length: maxFrets + 1 }).map((_, i) => { - if (i === 0) return null; // Nut handles 0 + if (i === 0) return null; const x = getFretLineX(i); return ( ); @@ -118,11 +126,7 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin {/* Strings (Horizontal lines) */} {tuning.strings.map((_, i) => { const y = getStringY(i); - - // i=0 is LOW pitch (thickest). - // Bolder: Increase base thick - const visualThickness = 4 - (i * 0.4); // 4 down to ~2 - + const visualThickness = 4 - (i * 0.4); return ( ); })} - {/* Target Note Positions */} - {positions.map((p, idx) => { - // Verify if fret is within range + {/* Target Note Positions (Hints) */} + {showHints && positions.map((p, idx) => { if (p.fret > maxFrets) return null; - const cx = getNoteX(p.fret); const cy = getStringY(p.stringIndex); return ( - + + {/* Add pointerEvents: none so hints don't block clicks if they overlay logic */} ); })} + + {/* Interaction Overlay (Invisible hit targets) */} + {interactive && tuning.strings.map((_, stringIndex) => { + const y = getStringY(stringIndex); + // Hit area height (centered on string) + const hitHeight = stringSpacing; // Full coverage between strings + const yStart = y - (hitHeight / 2); + + return Array.from({ length: maxFrets + 1 }).map((_, fret) => { + // Calculate X range for this fret + let xStart = 0; + let xEnd = 0; + + if (fret === 0) { + xStart = paddingX - 25; // Extend left a bit + xEnd = paddingX + nutWidth; + } else { + xStart = getFretLineX(fret - 1); + xEnd = getFretLineX(fret); + } + + const rectWidth = xEnd - xStart; + + return ( + handleFretClick(stringIndex, fret)} + // Hover effect could be added here via CSS class if we want + className="fret-hit-target" + > + String {stringIndex + 1}, Fret {fret} + + ); + }); + })}
);