mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-09-02 09:34:33 +02:00
Add piano as virtual instrument
This commit is contained in:
27
src/App.tsx
27
src/App.tsx
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
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',
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user