Add piano as virtual instrument

This commit is contained in:
2025-12-27 19:23:41 +01:00
parent ee36cf00cd
commit e97e036bdd
3 changed files with 292 additions and 14 deletions

View File

@@ -17,6 +17,7 @@ import {
} from './music/Tunings'; } from './music/Tunings';
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
import { Fretboard } from './components/Fretboard'; import { Fretboard } from './components/Fretboard';
import { PianoKeys } from './components/PianoKeys';
import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X, Guitar, Settings } from 'lucide-react'; import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X, Guitar, Settings } from 'lucide-react';
import './App.css'; import './App.css';
@@ -262,8 +263,8 @@ function App() {
} }
}, [settings.rhythm, restartMetronome, generateNewNote]); }, [settings.rhythm, restartMetronome, generateNewNote]);
// Virtual Guitar Handler // Virtual Instrument Handler (Guitar or Piano)
const handleVirtualGuitarPlay = useCallback((playedMidi: number) => { const handleVirtualInstrumentPlay = useCallback((playedMidi: number) => {
if (!settings.virtualGuitarMute) { if (!settings.virtualGuitarMute) {
playNote(playedMidi, 0.5, settings.virtualGuitarVolume ?? 0.5); // Feedback sound playNote(playedMidi, 0.5, settings.virtualGuitarVolume ?? 0.5); // Feedback sound
} }
@@ -469,15 +470,29 @@ function App() {
{getNoteDetails(targetMidi + activeTranspose).scientific} {getNoteDetails(targetMidi + activeTranspose).scientific}
</div> </div>
)} )}
{currentInstrumentDef.showTuning && currentTuning && (
{/* Virtual Instrument Display */}
{currentInstrumentDef.showTuning && (
currentInstrumentDef.id === 'piano' ? (
<PianoKeys
minMidi={36} // C2
maxMidi={84} // C6
markedNotes={settings.showHint ? [targetMidi] : []}
interactive={settings.showFretboard}
onPlayNote={handleVirtualInstrumentPlay}
/>
) : (
currentTuning && (
<Fretboard <Fretboard
tuning={currentTuning} tuning={currentTuning}
positions={hintPositions} positions={hintPositions}
interactive={settings.showFretboard} interactive={settings.showFretboard}
onPlayNote={handleVirtualGuitarPlay} onPlayNote={handleVirtualInstrumentPlay}
showHints={settings.showHint} showHints={settings.showHint}
maxFrets={15} maxFrets={15}
/> />
)
)
)} )}
</div> </div>
)} )}
@@ -490,10 +505,10 @@ function App() {
<button <button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`} className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))} onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title="Toggle Virtual Guitar" title={`Toggle Virtual ${currentInstrumentDef.displayName}`}
> >
<Guitar size={18} /> <Guitar size={18} />
Guitar {currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</button> </button>
)} )}

View File

@@ -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<HTMLDivElement>(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 (
<div className="piano-wrapper" style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Controls Bar */}
<div className="piano-controls" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 8px', fontSize: '12px', color: '#888' }}>
<div className="range-info">
Range: {keys.list[0]?.midi} - {keys.list[keys.list.length - 1]?.midi}
</div>
<div className="view-toggles" style={{ display: 'flex', gap: '8px', background: '#333', padding: '2px', borderRadius: '4px' }}>
<button
onClick={() => setViewMode('zoomed')}
style={{
background: viewMode === 'zoomed' ? '#666' : 'transparent',
border: 'none', color: 'white', padding: '2px 8px', borderRadius: '2px', cursor: 'pointer', fontSize: '10px'
}}
>
Zoom
</button>
<button
onClick={() => setViewMode('full')}
style={{
background: viewMode === 'full' ? '#666' : 'transparent',
border: 'none', color: 'white', padding: '2px 8px', borderRadius: '2px', cursor: 'pointer', fontSize: '10px'
}}
>
Full
</button>
</div>
</div>
{/* Piano Scroll Container */}
<div
ref={scrollRef}
className="piano-scroll-area"
onScroll={handleScroll}
style={{
width: '100%',
overflowX: viewMode === 'full' ? 'hidden' : 'auto',
borderTop: '1px solid #444',
background: '#222',
position: 'relative'
}}
>
<div style={{
width: viewMode === 'full' ? '100%' : `${totalWidthVal}px`,
height: `${height}px`,
position: 'relative'
}}>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${viewMode === 'full' ? 100 : totalWidthVal} 100`}
preserveAspectRatio="none"
style={{ display: 'block' }}
>
{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 (
<g key={k.midi} onClick={() => interactive && onPlayNote?.(k.midi)} style={{ cursor: interactive ? 'pointer' : 'default' }}>
<rect
x={k.x}
y={0}
width={k.width}
height={rectHeight}
fill={fill}
stroke={stroke}
strokeWidth={0.5}
rx={k.isBlack ? 0 : 2}
ry={k.isBlack ? 0 : 2}
/>
{/* Labels for C notes on white keys */}
{!k.isBlack && k.label && (
<text
x={k.x + (k.width / 2)}
y={90}
textAnchor="middle"
fontSize={viewMode === 'full' ? "4" : "12"}
fill="#999"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{k.label}
</text>
)}
{/* Marker dot if hinted? Existing marker logic just changes color, maybe dot is better? */}
{isMarked && (
<circle
cx={k.x + (k.width / 2)}
cy={k.isBlack ? rectHeight - 10 : 80}
r={viewMode === 'full' ? 1.5 : 4}
fill="red"
/>
)}
</g>
);
})}
</svg>
</div>
</div>
</div>
);
}

View File

@@ -90,7 +90,7 @@ export const INSTRUMENT_DEFINITIONS: Record<string, InstrumentDefinition> = {
displayName: 'Piano', displayName: 'Piano',
clefMode: 'grand', clefMode: 'grand',
transpose: 0, transpose: 0,
showTuning: false, showTuning: true,
ranges: [ ranges: [
{ id: 'middle_c', label: 'Middle C Area', type: 'static', min: 53, max: 67 }, // F3 to G4 { 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 { id: 'two_octave', label: 'Two Octaves', type: 'static', min: 48, max: 72 }, // C3 to C5