diff --git a/src/App.tsx b/src/App.tsx index 5b6a378..a341cc2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { SheetMusic } from './components/SheetMusic'; import { Controls, type AppSettings } from './components/Controls'; import { usePitchDetector } from './hooks/usePitchDetector'; import { useMetronome } from './hooks/useMetronome'; +import { useAudioPlayer } from './hooks/useAudioPlayer'; import { getRandomNote, getNoteDetails @@ -14,7 +15,7 @@ import { import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; import { FretboardHint } from './components/FretboardHint'; import { TuningMeter } from './components/TuningMeter'; -import { Mic, MicOff, SkipForward, HelpCircle } from 'lucide-react'; +import { Mic, MicOff, SkipForward, HelpCircle, Volume2 } from 'lucide-react'; import './App.css'; import './styles/skip-button.css'; @@ -23,6 +24,7 @@ const NOTE_MATCH_THRESHOLD_MS = 300; // How long to convert hold note to confirm function App() { const [listening, setListening] = useState(false); const { pitchData, error } = usePitchDetector(listening); + const { playNote } = useAudioPlayer(); const [targetMidi, setTargetMidi] = useState(60); // Start with C4 const [settings, setSettings] = useState({ @@ -41,11 +43,13 @@ function App() { sound: true, volume: 0.5 }, - zenMode: false + zenMode: false, + gameMode: 'sight_reading' }); const [matchStartTime, setMatchStartTime] = useState(null); const [feedbackMessage, setFeedbackMessage] = useState(""); + const [revealed, setRevealed] = useState(false); const currentTuning = TUNINGS[settings.tuningId]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; @@ -82,8 +86,20 @@ function App() { } setMatchStartTime(null); setFeedbackMessage(""); + setRevealed(false); }, [validNotes, targetMidi]); + // Audio Playback trigger + useEffect(() => { + if (settings.gameMode === 'ear_training' && !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 + }, 100); + return () => clearTimeout(timer); + } + }, [targetMidi, settings.gameMode, revealed, playNote]); + // Initial note useEffect(() => { generateNewNote(); @@ -130,6 +146,7 @@ function App() { if (duration > NOTE_MATCH_THRESHOLD_MS) { // Success! setFeedbackMessage("Good!"); + setRevealed(true); const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance; const isTimerMode = settings.rhythm.mode === 'seconds'; @@ -191,6 +208,7 @@ function App() { transpose={currentInstrumentDef.transpose} width={Math.min(window.innerWidth - 40, 500)} height={currentInstrumentDef.clefMode === 'grand' ? 300 : 250} + hideTargetNote={settings.gameMode === 'ear_training' && !revealed} /> {!settings.zenMode && ( @@ -198,11 +216,26 @@ function App() { {feedbackMessage ? (
{feedbackMessage}
) : ( -
Play the note above
+
+ {settings.gameMode === 'ear_training' ? "Listen and play the note" : "Play the note above"} +
)} )} + {settings.gameMode === 'ear_training' && !settings.zenMode && ( +
+ +
+ )} + {settings.showHint && (
diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index b529fec..cb822de 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -23,6 +23,7 @@ export interface AppSettings { showTuningMeter: boolean; rhythm: RhythmSettings; zenMode: boolean; + gameMode: 'sight_reading' | 'ear_training'; } interface ControlsProps { @@ -151,6 +152,28 @@ export const Controls: React.FC = ({ settings, onUpdateSettings }
+
+ +
+ + +
+
+ {/* Rhythm Controls */}
diff --git a/src/components/SheetMusic.tsx b/src/components/SheetMusic.tsx index b0fc2ed..30fa532 100644 --- a/src/components/SheetMusic.tsx +++ b/src/components/SheetMusic.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef } from 'react'; -import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, StaveConnector } from 'vexflow'; +import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, StaveConnector, GhostNote, Annotation } from 'vexflow'; import { getNoteInKey } from '../music/NoteUtils'; @@ -11,6 +11,7 @@ interface SheetMusicProps { height?: number; transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar) keySignature?: string; + hideTargetNote?: boolean; } export const SheetMusic: React.FC = ({ @@ -20,7 +21,8 @@ export const SheetMusic: React.FC = ({ width = 300, height = 250, // Increased default height for Grand Staff transpose = 12, // Default to +1 octave (Guitar Notation) - keySignature = 'C' + keySignature = 'C', + hideTargetNote = false }) => { const containerRef = useRef(null); @@ -111,14 +113,38 @@ export const SheetMusic: React.FC = ({ }; // --- Create Notes --- - const targetObj = createStaveNote(targetMidi, "w", 'target'); + let targetObj: { note: StaveNote | GhostNote, clef: string }; + + if (hideTargetNote) { + const visualMidi = targetMidi + transpose; + let noteClef = clef; + if (clef === 'grand') { + noteClef = getGrandStaffClef(visualMidi); + } + + // Create a GhostNote (invisible) instead of a StaveNote + // We use the same keys to ensure it takes up the right vertical space/clef logic + const data = getNoteInKey(visualMidi, keySignature); + const ghost = new GhostNote({ + keys: data.keys, + duration: "w", + clef: noteClef as 'treble' | 'bass' + }); + + // Add a Question Mark Annotation + ghost.addModifier(new Annotation("?").setVerticalJustification(Annotation.VerticalJustify.CENTER)); + + targetObj = { note: ghost, clef: noteClef }; + } else { + targetObj = createStaveNote(targetMidi, "w", 'target'); + } const voicesToDraw: { stave: Stave, voice: Voice }[] = []; // Helper to push voice - const addVoice = (stave: Stave, notes: StaveNote[]) => { + const addVoice = (stave: Stave, notes: (StaveNote | GhostNote)[]) => { const voice = new Voice({ numBeats: 4, beatValue: 4 }); - voice.addTickables(notes); + voice.addTickables(notes as any[]); new Formatter().joinVoices([voice]).format([voice], width - 60); voicesToDraw.push({ stave, voice }); }; @@ -126,7 +152,20 @@ export const SheetMusic: React.FC = ({ if (playedMidi) { const playedObj = createStaveNote(playedMidi, "h", 'played'); - const targetHalfObj = createStaveNote(targetMidi, "h", 'target'); + + let targetHalfObj: { note: StaveNote | GhostNote, clef: string }; + if (hideTargetNote) { + const visualMidi = targetMidi + transpose; + let noteClef = clef; + if (clef === 'grand') noteClef = getGrandStaffClef(visualMidi); + const data = getNoteInKey(visualMidi, keySignature); + + const ghost = new GhostNote({ keys: data.keys, duration: "h", clef: noteClef as 'treble' | 'bass' }); + ghost.addModifier(new Annotation("?").setVerticalJustification(Annotation.VerticalJustify.CENTER)); + targetHalfObj = { note: ghost, clef: noteClef }; + } else { + targetHalfObj = createStaveNote(targetMidi, "h", 'target'); + } // If Grand Staff: Notes might be on DIFFERENT staves. // We need to group notes by Stave. diff --git a/src/hooks/useAudioPlayer.ts b/src/hooks/useAudioPlayer.ts new file mode 100644 index 0000000..5dcb6bf --- /dev/null +++ b/src/hooks/useAudioPlayer.ts @@ -0,0 +1,62 @@ +import { useRef, useEffect, useCallback } from 'react'; +import { midiToFrequency } from '../music/NoteUtils'; + +interface AudioPlayerHandle { + playNote: (midi: number, duration?: number, volume?: number) => void; +} + +export function useAudioPlayer(): AudioPlayerHandle { + const audioContext = useRef(null); + + // Initialize AudioContext lazily or on user interaction if possible, + // but here we just ensure it exists when we try to play. + + const playNote = useCallback((midi: number, duration: number = 0.5, volume: number = 0.3) => { + if (!audioContext.current) { + audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)(); + } + + const ctx = audioContext.current; + if (ctx.state === 'suspended') { + ctx.resume(); + } + + const osc = ctx.createOscillator(); + const gainNode = ctx.createGain(); + + const freq = midiToFrequency(midi); + + osc.frequency.value = freq; + // Triangle wave is often nicer than sine for music training + osc.type = 'triangle'; + + osc.connect(gainNode); + gainNode.connect(ctx.destination); + + const now = ctx.currentTime; + + // Attack + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(volume, now + 0.02); + + // Sustain -> Release + // Note: For a "pluck" or single hit, we just ramp down. + gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration); + + osc.start(now); + osc.stop(now + duration + 0.1); // Stop slightly after fade out + + }, []); + + // Cleanup + useEffect(() => { + return () => { + if (audioContext.current) { + audioContext.current.close(); + audioContext.current = null; + } + }; + }, []); + + return { playNote }; +}