Ear training mode

This commit is contained in:
2025-12-23 14:40:34 +01:00
parent 30a52529d4
commit e57b790ab9
4 changed files with 166 additions and 9 deletions

View File

@@ -3,6 +3,7 @@ import { SheetMusic } from './components/SheetMusic';
import { Controls, type AppSettings } from './components/Controls'; import { Controls, type AppSettings } from './components/Controls';
import { usePitchDetector } from './hooks/usePitchDetector'; import { usePitchDetector } from './hooks/usePitchDetector';
import { useMetronome } from './hooks/useMetronome'; import { useMetronome } from './hooks/useMetronome';
import { useAudioPlayer } from './hooks/useAudioPlayer';
import { import {
getRandomNote, getRandomNote,
getNoteDetails getNoteDetails
@@ -14,7 +15,7 @@ import {
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
import { FretboardHint } from './components/FretboardHint'; import { FretboardHint } from './components/FretboardHint';
import { TuningMeter } from './components/TuningMeter'; 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 './App.css';
import './styles/skip-button.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() { function App() {
const [listening, setListening] = useState(false); const [listening, setListening] = useState(false);
const { pitchData, error } = usePitchDetector(listening); const { pitchData, error } = usePitchDetector(listening);
const { playNote } = useAudioPlayer();
const [targetMidi, setTargetMidi] = useState<number>(60); // Start with C4 const [targetMidi, setTargetMidi] = useState<number>(60); // Start with C4
const [settings, setSettings] = useState<AppSettings>({ const [settings, setSettings] = useState<AppSettings>({
@@ -41,11 +43,13 @@ function App() {
sound: true, sound: true,
volume: 0.5 volume: 0.5
}, },
zenMode: false zenMode: false,
gameMode: 'sight_reading'
}); });
const [matchStartTime, setMatchStartTime] = useState<number | null>(null); const [matchStartTime, setMatchStartTime] = useState<number | null>(null);
const [feedbackMessage, setFeedbackMessage] = useState<string>(""); const [feedbackMessage, setFeedbackMessage] = useState<string>("");
const [revealed, setRevealed] = useState(false);
const currentTuning = TUNINGS[settings.tuningId]; const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
@@ -82,8 +86,20 @@ function App() {
} }
setMatchStartTime(null); setMatchStartTime(null);
setFeedbackMessage(""); setFeedbackMessage("");
setRevealed(false);
}, [validNotes, targetMidi]); }, [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 // Initial note
useEffect(() => { useEffect(() => {
generateNewNote(); generateNewNote();
@@ -130,6 +146,7 @@ function App() {
if (duration > NOTE_MATCH_THRESHOLD_MS) { if (duration > NOTE_MATCH_THRESHOLD_MS) {
// Success! // Success!
setFeedbackMessage("Good!"); setFeedbackMessage("Good!");
setRevealed(true);
const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance; const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance;
const isTimerMode = settings.rhythm.mode === 'seconds'; const isTimerMode = settings.rhythm.mode === 'seconds';
@@ -191,6 +208,7 @@ function App() {
transpose={currentInstrumentDef.transpose} transpose={currentInstrumentDef.transpose}
width={Math.min(window.innerWidth - 40, 500)} width={Math.min(window.innerWidth - 40, 500)}
height={currentInstrumentDef.clefMode === 'grand' ? 300 : 250} height={currentInstrumentDef.clefMode === 'grand' ? 300 : 250}
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
/> />
{!settings.zenMode && ( {!settings.zenMode && (
@@ -198,11 +216,26 @@ function App() {
{feedbackMessage ? ( {feedbackMessage ? (
<div className="success-message animate-pop">{feedbackMessage}</div> <div className="success-message animate-pop">{feedbackMessage}</div>
) : ( ) : (
<div className="instruction-text">Play the note above</div> <div className="instruction-text">
{settings.gameMode === 'ear_training' ? "Listen and play the note" : "Play the note above"}
</div>
)} )}
</div> </div>
)} )}
{settings.gameMode === 'ear_training' && !settings.zenMode && (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '16px' }}>
<button
className="control-button"
onClick={() => playNote(targetMidi, 1.0)}
style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 16px' }}
>
<Volume2 size={24} />
Play Note
</button>
</div>
)}
{settings.showHint && ( {settings.showHint && (
<div className="hint-card"> <div className="hint-card">
<div className="hint-note"> <div className="hint-note">

View File

@@ -23,6 +23,7 @@ export interface AppSettings {
showTuningMeter: boolean; showTuningMeter: boolean;
rhythm: RhythmSettings; rhythm: RhythmSettings;
zenMode: boolean; zenMode: boolean;
gameMode: 'sight_reading' | 'ear_training';
} }
interface ControlsProps { interface ControlsProps {
@@ -151,6 +152,28 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
</select> </select>
</div> </div>
<div className="control-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%' }}>
<label className="control-label" style={{ marginBottom: '8px' }}>
<span>Game Mode</span>
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className={`control-button ${settings.gameMode === 'sight_reading' ? 'active' : ''}`}
onClick={() => onUpdateSettings({ ...settings, gameMode: 'sight_reading' })}
style={{ flex: 1 }}
>
Sight Reading
</button>
<button
className={`control-button ${settings.gameMode === 'ear_training' ? 'active' : ''}`}
onClick={() => onUpdateSettings({ ...settings, gameMode: 'ear_training' })}
style={{ flex: 1 }}
>
Ear Training
</button>
</div>
</div>
{/* Rhythm Controls */} {/* Rhythm Controls */}
<div className="control-group rhythm-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%' }}> <div className="control-group rhythm-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px', marginBottom: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px', marginBottom: '8px' }}>

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useRef } from 'react'; 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'; import { getNoteInKey } from '../music/NoteUtils';
@@ -11,6 +11,7 @@ interface SheetMusicProps {
height?: number; height?: number;
transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar) transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar)
keySignature?: string; keySignature?: string;
hideTargetNote?: boolean;
} }
export const SheetMusic: React.FC<SheetMusicProps> = ({ export const SheetMusic: React.FC<SheetMusicProps> = ({
@@ -20,7 +21,8 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
width = 300, width = 300,
height = 250, // Increased default height for Grand Staff height = 250, // Increased default height for Grand Staff
transpose = 12, // Default to +1 octave (Guitar Notation) transpose = 12, // Default to +1 octave (Guitar Notation)
keySignature = 'C' keySignature = 'C',
hideTargetNote = false
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -111,14 +113,38 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
}; };
// --- Create Notes --- // --- 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 }[] = []; const voicesToDraw: { stave: Stave, voice: Voice }[] = [];
// Helper to push 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 }); const voice = new Voice({ numBeats: 4, beatValue: 4 });
voice.addTickables(notes); voice.addTickables(notes as any[]);
new Formatter().joinVoices([voice]).format([voice], width - 60); new Formatter().joinVoices([voice]).format([voice], width - 60);
voicesToDraw.push({ stave, voice }); voicesToDraw.push({ stave, voice });
}; };
@@ -126,7 +152,20 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
if (playedMidi) { if (playedMidi) {
const playedObj = createStaveNote(playedMidi, "h", 'played'); 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. // If Grand Staff: Notes might be on DIFFERENT staves.
// We need to group notes by Stave. // We need to group notes by Stave.

View File

@@ -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<AudioContext | null>(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 };
}