diff --git a/src/App.tsx b/src/App.tsx index 459cafd..90aa398 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { } from './music/Tunings'; import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; import { Fretboard } from './components/Fretboard'; +import { PianoKeys } from './components/PianoKeys'; import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X, Guitar, Settings } from 'lucide-react'; import './App.css'; @@ -262,8 +263,8 @@ function App() { } }, [settings.rhythm, restartMetronome, generateNewNote]); - // Virtual Guitar Handler - const handleVirtualGuitarPlay = useCallback((playedMidi: number) => { + // Virtual Instrument Handler (Guitar or Piano) + const handleVirtualInstrumentPlay = useCallback((playedMidi: number) => { if (!settings.virtualGuitarMute) { playNote(playedMidi, 0.5, settings.virtualGuitarVolume ?? 0.5); // Feedback sound } @@ -469,15 +470,29 @@ function App() { {getNoteDetails(targetMidi + activeTranspose).scientific} )} - {currentInstrumentDef.showTuning && currentTuning && ( - + + {/* Virtual Instrument Display */} + {currentInstrumentDef.showTuning && ( + currentInstrumentDef.id === 'piano' ? ( + + ) : ( + currentTuning && ( + + ) + ) )} )} @@ -490,10 +505,10 @@ function App() { setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))} - title="Toggle Virtual Guitar" + title={`Toggle Virtual ${currentInstrumentDef.displayName}`} > - Guitar + {currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'} )} diff --git a/src/components/PianoKeys.tsx b/src/components/PianoKeys.tsx new file mode 100644 index 0000000..1386931 --- /dev/null +++ b/src/components/PianoKeys.tsx @@ -0,0 +1,263 @@ +import { useState, useRef, useEffect, useMemo } from 'react'; + +// Constants +const WHITE_KEY_WIDTH_PX = 40; +const BLACK_KEY_WIDTH_PERCENT = 0.6; // 60% of white key +const BLACK_KEY_HEIGHT_PERCENT = 0.65; // 65% of white key length + +interface PianoKeysProps { + minMidi?: number; // Default 21 (A0) + maxMidi?: number; // Default 108 (C8) + markedNotes?: number[]; // Notes to highlight (e.g. hints) + interactive?: boolean; + onPlayNote?: (midi: number) => void; + height?: number; +} + +export function PianoKeys({ + minMidi = 36, // C2 + maxMidi = 84, // C6 + markedNotes = [], + interactive = true, + onPlayNote, + height = 160 +}: PianoKeysProps) { + const [viewMode, setViewMode] = useState<'full' | 'zoomed'>('zoomed'); + const scrollRef = useRef(null); + + // Helpers to determine key type and position + const getIsBlackKey = (midi: number) => { + const n = midi % 12; + return [1, 3, 6, 8, 10].includes(n); + }; + + // Calculate the total number of WHITE keys in the range + // This allows us to size them evenly + const keys = useMemo(() => { + const k = []; + let whiteKeyCount = 0; + for (let i = minMidi; i <= maxMidi; i++) { + const isBlack = getIsBlackKey(i); + if (!isBlack) whiteKeyCount++; + k.push({ midi: i, isBlack }); + } + return { list: k, whiteKeyCount }; + }, [minMidi, maxMidi]); + + // Handle Scrolling logic for zoomed view + const handleScroll = () => { + // Placeholder if we need scroll position logic later + }; + + // Scroll to center initially or when switching to zoomed + useEffect(() => { + if (viewMode === 'zoomed' && scrollRef.current) { + const container = scrollRef.current; + const contentWidth = container.scrollWidth; + const clientWidth = container.clientWidth; + // Center it roughly around Middle C (60) or the center of range + // For now, simpler: center the scroll view + container.scrollLeft = (contentWidth - clientWidth) / 2; + } + }, [viewMode]); + + + // We'll use SVG for precision + // 1. Calculate White Key Width based on Total Width + // In 'full' mode: width = 100% / whiteKeyCount + // In 'zoomed' mode: width = fixed pixel value (e.g. 40px) + + const whiteKeyWidth = viewMode === 'full' ? 100 / keys.whiteKeyCount : WHITE_KEY_WIDTH_PX; // % or px + const totalWidthVal = viewMode === 'full' ? 100 : keys.whiteKeyCount * WHITE_KEY_WIDTH_PX; + + // Generate Key Definitions for SVG + const keyRects = useMemo(() => { + const rects = []; + let currentWhiteIndex = 0; + + // Pass 1: White Keys + for (const k of keys.list) { + if (!k.isBlack) { + rects.push({ + midi: k.midi, + isBlack: false, + x: currentWhiteIndex * whiteKeyWidth, + width: whiteKeyWidth, + height: 100, // 100% + isC: k.midi % 12 === 0, + label: (k.midi % 12 === 0) ? `C${Math.floor(k.midi / 12) - 1}` : null + }); + currentWhiteIndex++; + } + } + + // Pass 2: Black Keys + // Black keys are positioned between white keys. + // C# is between 0 and 1 (C and D) + // Offset is usually: + // C# : center on boundary of C/D + // D# : center on boundary of D/E + // F# : center on boundary of F/G + // G# : center on boundary of G/A + // A# : center on boundary of A/B + + // We need to find the X of the LEFT white key. + // If Midi is M (black), left white is M-1. + // Wait, not always. + // C(0), C#(1), D(2) -> C# is between C and D. + // F(5), F#(6), G(7) -> F# is between F and G. + + // Let's iterate again and calculate. + let whiteIndex = 0; + for (const k of keys.list) { + if (!k.isBlack) { + whiteIndex++; + } else { + // It's a black key. It sits on top of the boundary after the (whiteIndex - 1)th white key. + // Center of black key ~= Right edge of previous white key. + // Fine tuning: varying visual offsets exist, but centering is safe for virtual piano. + + // The C key is at index 0. Its right edge is (0+1) * w = w. + // So center of C# is at w. + + const blackWidth = whiteKeyWidth * BLACK_KEY_WIDTH_PERCENT; + const x = (whiteIndex * whiteKeyWidth) - (blackWidth / 2); + + rects.push({ + midi: k.midi, + isBlack: true, + x: x, + width: blackWidth, + height: 65, // % height + isC: false + }); + } + } + + // Sort specifically so black keys are last (on top in SVG z-order) + return rects.sort((a, b) => (a.isBlack === b.isBlack) ? 0 : a.isBlack ? 1 : -1); + + }, [keys, whiteKeyWidth, viewMode]); + + + return ( + + + {/* Controls Bar */} + + + Range: {keys.list[0]?.midi} - {keys.list[keys.list.length - 1]?.midi} + + + + setViewMode('zoomed')} + style={{ + background: viewMode === 'zoomed' ? '#666' : 'transparent', + border: 'none', color: 'white', padding: '2px 8px', borderRadius: '2px', cursor: 'pointer', fontSize: '10px' + }} + > + Zoom + + setViewMode('full')} + style={{ + background: viewMode === 'full' ? '#666' : 'transparent', + border: 'none', color: 'white', padding: '2px 8px', borderRadius: '2px', cursor: 'pointer', fontSize: '10px' + }} + > + Full + + + + + {/* Piano Scroll Container */} + + + + {keyRects.map(k => { + const isMarked = markedNotes.includes(k.midi); + + // Styling + let fill = k.isBlack ? '#222' : '#fff'; + if (interactive && isMarked) { + fill = k.isBlack ? '#d32f2f' : '#ffcdd2'; // Red-ish for marked + } + + const stroke = '#000'; + + // Height needs to be converted if in px mode? No, SVG viewBox handles scale for Y if we used 100... + // But wait. + // If viewMode is 'zoomed', viewBox is `0 0 totalWidthVal 100`. + // So Y coordinates are 0-100. + // If viewMode is 'full', viewBox is `0 0 100 100`. + // So Y coordinates are 0-100. + // Perfect. + + const rectHeight = k.isBlack ? BLACK_KEY_HEIGHT_PERCENT * 100 : 100; + + return ( + interactive && onPlayNote?.(k.midi)} style={{ cursor: interactive ? 'pointer' : 'default' }}> + + {/* Labels for C notes on white keys */} + {!k.isBlack && k.label && ( + + {k.label} + + )} + {/* Marker dot if hinted? Existing marker logic just changes color, maybe dot is better? */} + {isMarked && ( + + )} + + ); + })} + + + + + ); +} diff --git a/src/music/InstrumentConfigs.ts b/src/music/InstrumentConfigs.ts index 2563320..b33d2c9 100644 --- a/src/music/InstrumentConfigs.ts +++ b/src/music/InstrumentConfigs.ts @@ -90,7 +90,7 @@ export const INSTRUMENT_DEFINITIONS: Record = { displayName: 'Piano', clefMode: 'grand', transpose: 0, - showTuning: false, + showTuning: true, ranges: [ { id: 'middle_c', label: 'Middle C Area', type: 'static', min: 53, max: 67 }, // F3 to G4 { id: 'two_octave', label: 'Two Octaves', type: 'static', min: 48, max: 72 }, // C3 to C5