diff --git a/src/App.tsx b/src/App.tsx index c9c9e63..11ce3f2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,10 +8,9 @@ import { } from './music/NoteUtils'; import { TUNINGS, - getOpenStringNotes, - getFirstPositionNotes, getFretboardPositions } from './music/Tunings'; +import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; import { Mic, MicOff, SkipForward } from 'lucide-react'; import './App.css'; import './styles/skip-button.css'; @@ -24,10 +23,11 @@ function App() { const [targetMidi, setTargetMidi] = useState(60); // Start with C4 const [settings, setSettings] = useState({ - difficulty: 'first_pos', + difficulty: 'first_pos', // Will be dynamic, but initial default needed showHint: false, tuningId: 'standard', - keySignature: 'C' + keySignature: 'C', + instrument: 'guitar' }); const [streak, setStreak] = useState(0); @@ -35,30 +35,34 @@ function App() { const [feedbackMessage, setFeedbackMessage] = useState(""); const currentTuning = TUNINGS[settings.tuningId]; + const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; // Generate valid notes based on difficulty const validNotes = useMemo(() => { - switch (settings.difficulty) { - case 'open': - return getOpenStringNotes(currentTuning); - case 'first_pos': - return getFirstPositionNotes(currentTuning); - case 'e_string': - // Low E string: 40 to 40+12 (E2 to E3) - const lowE = currentTuning.strings[0]; - return Array.from({ length: 13 }, (_, i) => lowE + i); - case 'all': - default: - // Range from Low E (40) to High E 12th fret (64+12=76) - return Array.from({ length: 37 }, (_, i) => 40 + i); + // Find range config + const rangeConfig = currentInstrumentDef.ranges.find(r => r.id === settings.difficulty); + + if (rangeConfig) { + return Array.from({ length: rangeConfig.max - rangeConfig.min + 1 }, (_, i) => rangeConfig.min + i); } - }, [settings.difficulty, currentTuning]); + + // 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 Array.from({ length: fallbackRange.max - fallbackRange.min + 1 }, (_, i) => fallbackRange.min + i); + + }, [settings.difficulty, currentInstrumentDef]); const generateNewNote = useCallback(() => { - const newNote = getRandomNote(40, 76, validNotes); + // 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(40, 76, validNotes); + const retry = getRandomNote(min, max, validNotes); setTargetMidi(retry); } else { setTargetMidi(newNote); @@ -108,8 +112,10 @@ function App() { // Hint text construction const hintPositions = useMemo(() => { if (!settings.showHint) return []; + if (!currentInstrumentDef.showTuning || !currentTuning) return []; // No fretboard hints for piano/voice + return getFretboardPositions(targetMidi, currentTuning); - }, [settings.showHint, targetMidi, currentTuning]); + }, [settings.showHint, targetMidi, currentTuning, currentInstrumentDef]); return (
@@ -124,8 +130,10 @@ function App() { targetMidi={targetMidi} playedMidi={pitchData?.midi} keySignature={settings.keySignature} + clef={currentInstrumentDef.clefMode} + transpose={currentInstrumentDef.transpose} width={Math.min(window.innerWidth - 40, 500)} - height={250} + height={currentInstrumentDef.clefMode === 'grand' ? 300 : 250} />
@@ -136,10 +144,10 @@ function App() { )}
- {settings.showHint && ( + {settings.showHint && currentInstrumentDef.showTuning && (
- {getNoteDetails(targetMidi + 12).scientific} (Written) + {getNoteDetails(targetMidi + currentInstrumentDef.transpose).scientific} (Written)
{hintPositions.map((p, i) => ( diff --git a/src/audio/PitchAnalyzer.ts b/src/audio/PitchAnalyzer.ts index 2dd717f..f8ed40b 100644 --- a/src/audio/PitchAnalyzer.ts +++ b/src/audio/PitchAnalyzer.ts @@ -62,12 +62,8 @@ export class PitchAnalyzer { const pitch = this.detector(this.buffer); - // Guitar range filtering: - // Low E (E2) is ~82Hz. Drop D is ~73Hz. - // High E (E4) is ~330Hz. 12th fret E5 is ~660Hz. - // Harmonics can go higher, but unlikely above 1500Hz for fundamental training. - // 19kHz (user reported D#10) is definitely noise. - if (pitch && (pitch < 70 || pitch > 1500)) return null; + // Widen range for Bass (E1 ~41Hz) and Whistle (C8 ~4186Hz) + if (pitch && (pitch < 30 || pitch > 5000)) return null; return pitch; } diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index 05f72a2..cd9431d 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import { Settings, HelpCircle, Guitar } from 'lucide-react'; -import { TUNINGS } from '../music/Tunings'; +import { Settings, HelpCircle, Guitar, Music } from 'lucide-react'; +import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; +import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; export type Difficulty = 'all' | 'first_pos' | 'open' | 'e_string'; @@ -9,6 +10,7 @@ export interface AppSettings { showHint: boolean; tuningId: string; keySignature: string; + instrument: string; } interface ControlsProps { @@ -25,6 +27,31 @@ export const Controls: React.FC = ({ settings, onUpdateSettings } onUpdateSettings({ ...settings, tuningId: e.target.value }); }; + const handleInstrumentChange = (e: React.ChangeEvent) => { + const newInstrumentId = e.target.value; + const instDef = INSTRUMENT_DEFINITIONS[newInstrumentId]; + + const defaultRange = instDef.ranges[0].id; + + // Reset tuning if applicable, or just keep as is (it won't be shown/used) + // If instrument has tunings, pick first. + let newTuningId = settings.tuningId; + if (instDef.showTuning && INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass']) { + newTuningId = INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass'][0]; + } + + onUpdateSettings({ + ...settings, + instrument: newInstrumentId, + difficulty: defaultRange as Difficulty, // flexible casting + tuningId: newTuningId + }); + }; + + // Cast instrument to specific key if needed, or use generic record access + const availableTunings = INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] || []; + const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; + const toggleHint = () => { onUpdateSettings({ ...settings, showHint: !settings.showHint }); }; @@ -33,20 +60,38 @@ export const Controls: React.FC = ({ settings, onUpdateSettings }
+ {currentInstrumentDef.showTuning && ( +
+ + +
+ )} +
diff --git a/src/components/SheetMusic.tsx b/src/components/SheetMusic.tsx index e722081..b0fc2ed 100644 --- a/src/components/SheetMusic.tsx +++ b/src/components/SheetMusic.tsx @@ -1,12 +1,12 @@ import React, { useEffect, useRef } from 'react'; -import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter } from 'vexflow'; +import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, StaveConnector } from 'vexflow'; import { getNoteInKey } from '../music/NoteUtils'; interface SheetMusicProps { targetMidi: number; playedMidi?: number | null; - clef?: 'treble' | 'bass'; + clef?: 'treble' | 'bass' | 'grand'; width?: number; height?: number; transpose?: number; // Transposition in semitones for visualization (e.g., +12 for guitar) @@ -18,7 +18,7 @@ export const SheetMusic: React.FC = ({ playedMidi, clef = 'treble', width = 300, - height = 200, + height = 250, // Increased default height for Grand Staff transpose = 12, // Default to +1 octave (Guitar Notation) keySignature = 'C' }) => { @@ -31,77 +31,236 @@ export const SheetMusic: React.FC = ({ containerRef.current.innerHTML = ''; const renderer = new Renderer(containerRef.current, Renderer.Backends.SVG); - renderer.resize(width, height); const context = renderer.getContext(); - // Create Stave - const stave = new Stave(10, 40, width - 20); - stave.addClef(clef); - stave.addKeySignature(keySignature); - stave.setContext(context).draw(); - - // Helper to create keys for VexFlow using key signature logic - const getVexFlowKey = (midi: number) => { - const visualMidi = midi + transpose; - return getNoteInKey(visualMidi, keySignature); + // --- Helper: Decide which clef a note belongs to in Grand Staff --- + // For Grand Staff: usually Split at Middle C (C4 / Midi 60). + // >= 60 -> Treble, < 60 -> Bass. + const getGrandStaffClef = (midi: number): 'treble' | 'bass' => { + return midi >= 60 ? 'treble' : 'bass'; }; - // Create Target Note - const targetData = getVexFlowKey(targetMidi); + let staves: Record = {}; - const targetStaveNote = new StaveNote({ - keys: targetData.keys, - duration: "w", - clef: clef - }); + if (clef === 'grand') { + // Create Treble Stave + const topStave = new Stave(20, 40, width - 30); + topStave.addClef('treble').addKeySignature(keySignature); + topStave.setContext(context).draw(); - if (targetData.accidental) { - targetStaveNote.addModifier(new Accidental(targetData.accidental)); - } + // Create Bass Stave + const bottomStave = new Stave(20, 150, width - 30); + bottomStave.addClef('bass').addKeySignature(keySignature); + bottomStave.setContext(context).draw(); - if (playedMidi) { - const playedData = getVexFlowKey(playedMidi); + // Connect them + const brace = new StaveConnector(topStave, bottomStave); + brace.setType(StaveConnector.type.BRACE); + brace.setContext(context).draw(); - const playedStaveNote = new StaveNote({ - keys: playedData.keys, - duration: "h", - clef: clef - }); + const leftLine = new StaveConnector(topStave, bottomStave); + leftLine.setType(StaveConnector.type.SINGLE_LEFT); + leftLine.setContext(context).draw(); - // Target note as half note to match measure - const targetNoteHalf = new StaveNote({ - keys: targetData.keys, - duration: "h", - clef: clef - }); - if (targetData.accidental) targetNoteHalf.addModifier(new Accidental(targetData.accidental)); + const rightLine = new StaveConnector(topStave, bottomStave); + rightLine.setType(StaveConnector.type.SINGLE_RIGHT); + rightLine.setContext(context).draw(); - - if (playedMidi === targetMidi) { - playedStaveNote.setStyle({ fillStyle: "var(--color-success)", strokeStyle: "var(--color-success)" }); - } else { - playedStaveNote.setStyle({ fillStyle: "var(--color-error)", strokeStyle: "var(--color-error)" }); - } - - if (playedData.accidental) { - playedStaveNote.addModifier(new Accidental(playedData.accidental)); - } - - // Use camelCase properties as fixed previously - const voiceCombined = new Voice({ numBeats: 4, beatValue: 4 }); - voiceCombined.addTickables([targetNoteHalf, playedStaveNote]); - - new Formatter().joinVoices([voiceCombined]).format([voiceCombined], width - 50); - voiceCombined.draw(context, stave); + staves = { treble: topStave, bass: bottomStave }; } else { - const voice = new Voice({ numBeats: 4, beatValue: 4 }); - voice.addTickables([targetStaveNote]); - new Formatter().joinVoices([voice]).format([voice], width - 50); - voice.draw(context, stave); + // Single Stave + const stave = new Stave(10, 80, width - 20); // Centered vertically + stave.addClef(clef).addKeySignature(keySignature); + stave.setContext(context).draw(); + // Map the single clef to the key matching the 'clef' prop so logic below works + staves = { [clef]: stave }; } + // --- Helper: Create VexFlow Note --- + const createStaveNote = (midi: number, duration: string, type: 'target' | 'played') => { + const visualMidi = midi + transpose; + const data = getNoteInKey(visualMidi, keySignature); + + // Determine Clef for THIS note + let noteClef = clef; + if (clef === 'grand') { + noteClef = getGrandStaffClef(visualMidi); + } + + const staveNote = new StaveNote({ + keys: data.keys, + duration: duration, + clef: noteClef as 'treble' | 'bass' + }); + + if (data.accidental) { + staveNote.addModifier(new Accidental(data.accidental)); + } + + if (type === 'played') { + if (midi === targetMidi) { + staveNote.setStyle({ fillStyle: "var(--color-success)", strokeStyle: "var(--color-success)" }); + } else { + staveNote.setStyle({ fillStyle: "var(--color-error)", strokeStyle: "var(--color-error)" }); + } + } + + return { note: staveNote, clef: noteClef }; + }; + + // --- Create Notes --- + const targetObj = createStaveNote(targetMidi, "w", 'target'); + + const voicesToDraw: { stave: Stave, voice: Voice }[] = []; + + // Helper to push voice + const addVoice = (stave: Stave, notes: StaveNote[]) => { + const voice = new Voice({ numBeats: 4, beatValue: 4 }); + voice.addTickables(notes); + new Formatter().joinVoices([voice]).format([voice], width - 60); + voicesToDraw.push({ stave, voice }); + }; + + + if (playedMidi) { + const playedObj = createStaveNote(playedMidi, "h", 'played'); + const targetHalfObj = createStaveNote(targetMidi, "h", 'target'); + + // If Grand Staff: Notes might be on DIFFERENT staves. + // We need to group notes by Stave. + + const groupedNotes: Record = {}; + + // Initialize relevant keys + if (clef === 'grand') { + groupedNotes['treble'] = []; + groupedNotes['bass'] = []; + } else { + groupedNotes[clef] = []; + } + + // Logic: + // If Single Stave: Both notes go on that stave. + // If Grand Staff: Target goes on its clef. Played goes on its clef. + // BUT: If they are on the SAME clef, we render them in one voice (or same stave). + // VexFlow requires Formatter to format voices. + + // Target Half + // If clef is grand, use targetHalfObj.clef. If single, use prop clef. + const targetClefKey = clef === 'grand' ? targetHalfObj.clef : clef; + // Played + const playedClefKey = clef === 'grand' ? playedObj.clef : clef; + + // Wait, if we want them to align in time (same measure), we need to put them in the same voice OR separate voices in same Context? + // VexFlow: To draw notes side-by-side (sequentially), they are in the same Voice. + // The prompt implies "Target note as half / Played as half". + + // COMPLEXITY: In Grand Staff, if Target is Treble and Played is Bass, they are on different staves. + // They are distinct events visually. + // If both are Treble, they are side-by-side. + + // Let's create a map of Voice-per-Stave. + + // We need to fill "rests" or manage timing if they are split across staves? + // Simplified: Just draw them. If they are on different staves, they won't align horizontally perfectly unless we coordinate formatters. + // For this app, simply drawing them on their respective staves is fine. + + // However, to ensure they look like a "measure", we should probably put Rests? + // Let's keep it simple: Just draw the notes. + + // Problem: If I play C3 (Bass) and Target is C5 (Treble). + // Treble Stave: [C5 (h), Rest (h)] ? Or just C5 at pos 0? + // If we just addtickables, they render at start. + + // To align them: + // Ideally: + // Target (h) -> Beat 1 + // Played (h) -> Beat 3 + // So: + // Voice 1 (Target's Stave): Note(h) + Rest(h) (if played is elsewhere?) + // Actually, existing code did: [Target(h), Played(h)]. Sequence. + + // Scenario 1: Both on same stave. + if (targetClefKey === playedClefKey) { + // Same stave. Add both to voice. + const stv = staves[targetClefKey as string]; + addVoice(stv, [targetHalfObj.note, playedObj.note]); + } else { + // Different staves (Grand Staff split). + // Target on Stave A. Played on Stave B. + // Stave A: Target(h) + Rest(h) (invisible?) + // Stave B: Rest(h) + Played(h) + + // Constructing invisible rests is tedious in Vexflow without dedicated Rest classes. + // Let's try separate Voices? + // Visual separation might be okay. + + const staveT = staves[targetClefKey as string]; + const staveP = staves[playedClefKey as string]; + + // Just draw them. + // Note: They will both appear at the start (Beat 1) if we don't padding. + // We want Played to be "next" to Target. + + // Let's stick to the "Sequence": Target then Played. + // If separate staves, we lose the "sequence" visual left-to-right if we just draw them at beat 1. + + // SOLUTION: Use a "Ghost Note" (Invisible) of Half duration on the other stave? + // Or proper VexFlow StaveGhostNote? + + // Simpler hack: + // Render Target at Beat 1. + // Render Played at Beat 3. + + // For Stave A (Target): Note(h), Rest(h) + // For Stave B (Played): Rest(h), Note(h) + + // Vexflow `StaveNote({ keys: ["b/4"], duration: "hqr" })` for rest? + + const createRest = (clefStr: string) => new StaveNote({ keys: ["b/4"], duration: "hr", clef: clefStr }); + + // Stave T (Target's stave) + const voiceT = new Voice({ numBeats: 4, beatValue: 4 }); + voiceT.addTickables([targetHalfObj.note, createRest(targetClefKey as string)]); + + // Stave P (Played's stave) + const voiceP = new Voice({ numBeats: 4, beatValue: 4 }); + voiceP.addTickables([createRest(playedClefKey as string), playedObj.note]); + + // We need to format them together to align beats? + // Yes, formatters can take multiple voices to align specific ticks. + + new Formatter().joinVoices([voiceT]).format([voiceT], width - 60); + new Formatter().joinVoices([voiceP]).format([voiceP], width - 60); + + // But wait, if we format separately, they might not align vertically across staves? + // Actually they will if width is same. + // But generally `joinVoices([v1, v2])` is better. + + // But vT and vP are on different staves! + // VexFlow Formatter doesn't care about staves, just X alignment. + // So we can format them together! + + new Formatter().joinVoices([voiceT, voiceP]).format([voiceT, voiceP], width - 60); + + voicesToDraw.push({ stave: staveT, voice: voiceT }); + voicesToDraw.push({ stave: staveP, voice: voiceP }); + } + + } else { + // Target Only (Whole note) + const stave = staves[targetObj.clef as string]; + addVoice(stave, [targetObj.note]); + } + + // Draw all voices + voicesToDraw.forEach(({ stave, voice }) => { + voice.draw(context, stave); + }); + + }, [targetMidi, playedMidi, clef, width, height, transpose, keySignature]); return
; diff --git a/src/music/InstrumentConfigs.ts b/src/music/InstrumentConfigs.ts new file mode 100644 index 0000000..cde54a1 --- /dev/null +++ b/src/music/InstrumentConfigs.ts @@ -0,0 +1,86 @@ +import type { Instrument } from './Tunings'; + +export type ClefMode = 'treble' | 'bass' | 'grand'; + +export interface RangeConfig { + id: string; + label: string; + min: number; // MIDI number + max: number; // MIDI number +} + +export interface InstrumentDefinition { + id: Instrument | 'piano' | 'voice' | 'whistle'; + displayName: string; + clefMode: ClefMode; + transpose: number; // Semitones to add to MIDI to get written note (e.g. +12 for guitar) + ranges: RangeConfig[]; + showTuning: boolean; +} + +export const INSTRUMENT_DEFINITIONS: Record = { + guitar: { + id: 'guitar', + displayName: 'Guitar', + clefMode: 'treble', + transpose: 12, // Guitar sounds octave lower than written, so we add 12 to played midi to show it + showTuning: true, + ranges: [ + { id: 'open', label: 'Open Strings', min: 40, max: 64 }, // Dynamic logic handled in App for specific strings, but this is fallback + { id: 'first_pos', label: 'First Position', min: 40, max: 44 + 12 }, // Approx + { id: 'all', label: 'All Notes', min: 40, max: 76 } + ] + }, + bass: { + id: 'bass', + displayName: 'Bass Guitar', + clefMode: 'bass', + transpose: 12, + showTuning: true, + ranges: [ + // Bass Standard: E1 (28) -> G3 approx (55) + { id: 'all', label: 'All Notes', min: 28, max: 55 } + ] + }, + piano: { + id: 'piano', + displayName: 'Piano', + clefMode: 'grand', + transpose: 0, + showTuning: false, + ranges: [ + { id: 'middle_c', label: 'Middle C Area', min: 53, max: 67 }, // F3 to G4 + { id: 'two_octave', label: 'Two Octaves', min: 48, max: 72 }, // C3 to C5 + { id: 'grand_staff', label: 'Grand Staff Wide', min: 36, max: 84 } // C2 to C6 + ] + }, + voice: { + id: 'voice', + displayName: 'Voice', + clefMode: 'treble', // Dynamic? Usually vocal music is specific clef OR treble w/ 8va. Let's stick to Treble/Bass per range? + // Actually, let's keep simple static clef or make App logic handle it. + // For now, Voice is usually Treble unless Bass/Baritone. + // Let's use Treble by default and maybe switch if range is low. + transpose: 0, + showTuning: false, + ranges: [ + { id: 'soprano', label: 'Soprano (C4-A5)', min: 60, max: 81 }, + { id: 'alto', label: 'Alto (G3-E5)', min: 55, max: 76 }, + { id: 'tenor', label: 'Tenor (C3-A4)', min: 48, max: 69 }, + { id: 'bass_voice', label: 'Bass (E2-E4)', min: 40, max: 64 } + ] + }, + whistle: { + id: 'whistle', + displayName: 'Whistle', + clefMode: 'treble', + transpose: -12, // Reads 8va (written C4 = sounding C5). E5 (76) sounds -> reads as E4. C8 (108) -> C7. + showTuning: false, + ranges: [ + // "starts around E5 for men" -> E5 = 76. + { id: 'basic', label: 'Basic (E5-E6)', min: 76, max: 88 }, + { id: 'extended', label: 'Extended (E5-E7)', min: 76, max: 100 }, + { id: 'extreme', label: 'Whistle Register (C6-C8)', min: 84, max: 108 } + ] + } +}; diff --git a/src/music/Tunings.ts b/src/music/Tunings.ts index c046d18..d83321b 100644 --- a/src/music/Tunings.ts +++ b/src/music/Tunings.ts @@ -5,21 +5,35 @@ export interface Tuning { strings: number[]; // MIDI numbers for strings, usually low to high (6th to 1st) } +export type Instrument = 'guitar' | 'bass'; + // Low E2 (40), A2 (45), D3 (50), G3 (55), B3 (59), E4 (64) export const STANDARD_TUNING: Tuning = { - name: "Standard", + name: "Standard (Guitar)", strings: [40, 45, 50, 55, 59, 64] }; // Low D2 (38), A2 (45), D3 (50), G3 (55), B3 (59), E4 (64) export const DROP_D_TUNING: Tuning = { - name: "Drop D", + name: "Drop D (Guitar)", strings: [38, 45, 50, 55, 59, 64] }; +// Bass Standard: E1 (28), A1 (33), D2 (38), G2 (43) +export const BASS_STANDARD_TUNING: Tuning = { + name: "Standard (Bass)", + strings: [28, 33, 38, 43] +}; + export const TUNINGS: Record = { "standard": STANDARD_TUNING, "drop_d": DROP_D_TUNING, + "bass_standard": BASS_STANDARD_TUNING, +}; + +export const INSTRUMENT_TUNINGS: Record = { + 'guitar': ['standard', 'drop_d'], + 'bass': ['bass_standard'] }; export interface FretPosition {