mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-09-02 01:24:33 +02:00
Add piano as virtual instrument
This commit is contained in:
41
src/App.tsx
41
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}
|
||||
</div>
|
||||
)}
|
||||
{currentInstrumentDef.showTuning && currentTuning && (
|
||||
<Fretboard
|
||||
tuning={currentTuning}
|
||||
positions={hintPositions}
|
||||
interactive={settings.showFretboard}
|
||||
onPlayNote={handleVirtualGuitarPlay}
|
||||
showHints={settings.showHint}
|
||||
maxFrets={15}
|
||||
/>
|
||||
|
||||
{/* 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
|
||||
tuning={currentTuning}
|
||||
positions={hintPositions}
|
||||
interactive={settings.showFretboard}
|
||||
onPlayNote={handleVirtualInstrumentPlay}
|
||||
showHints={settings.showHint}
|
||||
maxFrets={15}
|
||||
/>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -490,10 +505,10 @@ function App() {
|
||||
<button
|
||||
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
||||
title="Toggle Virtual Guitar"
|
||||
title={`Toggle Virtual ${currentInstrumentDef.displayName}`}
|
||||
>
|
||||
<Guitar size={18} />
|
||||
Guitar
|
||||
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
||||
263
src/components/PianoKeys.tsx
Normal file
263
src/components/PianoKeys.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +90,7 @@ export const INSTRUMENT_DEFINITIONS: Record<string, InstrumentDefinition> = {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user