Virtual guitar

This commit is contained in:
2025-12-27 13:24:59 +01:00
parent 31843095a3
commit 5a78d0ff04
3 changed files with 207 additions and 97 deletions

View File

@@ -15,9 +15,9 @@ import {
getFirstPositionNotes, getFirstPositionNotes,
} from './music/Tunings'; } from './music/Tunings';
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
import { FretboardHint } from './components/FretboardHint'; import { Fretboard } from './components/Fretboard';
import { TuningMeter } from './components/TuningMeter';
import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X } from 'lucide-react'; import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X, Guitar } from 'lucide-react';
import './App.css'; import './App.css';
import './styles/skip-button.css'; import './styles/skip-button.css';
@@ -32,6 +32,7 @@ function App() {
const [settings, setSettings] = useState<AppSettings>({ const [settings, setSettings] = useState<AppSettings>({
difficulty: 'first_pos', difficulty: 'first_pos',
showHint: false, showHint: false,
showFretboard: false,
showTuningMeter: false, showTuningMeter: false,
tuningId: 'standard', tuningId: 'standard',
keySignature: 'C', keySignature: 'C',
@@ -49,7 +50,8 @@ function App() {
gameMode: 'sight_reading', gameMode: 'sight_reading',
customMinFret: 0, customMinFret: 0,
customMaxFret: 12, customMaxFret: 12,
autoPlaySightReading: false autoPlaySightReading: false,
autoPlayVolume: 0.5
}); });
const [matchStartTime, setMatchStartTime] = useState<number | null>(null); const [matchStartTime, setMatchStartTime] = useState<number | null>(null);
@@ -160,7 +162,7 @@ function App() {
if (shouldAutoPlay && !revealed) { if (shouldAutoPlay && !revealed) {
// Add a small delay to ensure state settles or allow UI to update // Add a small delay to ensure state settles or allow UI to update
const timer = setTimeout(() => { const timer = setTimeout(() => {
playNote(targetMidi, 1.0); // Play for 1 second playNote(targetMidi, 1.0, settings.autoPlayVolume ?? 0.5); // Play for 1 second
}, 100); }, 100);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
@@ -197,6 +199,49 @@ function App() {
}); });
// Common success handler
const handleMatchSuccess = useCallback(() => {
// Success!
setFeedbackMessage("Good!");
setRevealed(true);
setMatchStartTime(null);
const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance;
const isTimerMode = settings.rhythm.mode === 'seconds';
if (!isRhythmActive) {
// Standard or Rhythm-Manual
setTimeout(() => {
generateNewNote();
}, 800);
} else {
// Rhythm Active AND Auto-Advance
if (isTimerMode) {
// Dynamic Timer Mode: Success triggers advance
restartMetronome(); // Reset the countdown
setTimeout(() => {
generateNewNote();
}, 200);
setMatchStartTime(null);
} else {
// Strict BPM Mode: Consumed success, but wait for tick.
// Logic handled by tick
}
}
}, [settings.rhythm, restartMetronome, generateNewNote]);
// Virtual Guitar Handler
const handleVirtualGuitarPlay = useCallback((playedMidi: number) => {
playNote(playedMidi, 0.5); // Feedback sound
if (playedMidi === targetMidi) {
// Instant match
if (feedbackMessage !== "Good!") {
handleMatchSuccess();
}
}
}, [playNote, targetMidi, feedbackMessage, handleMatchSuccess]);
// Match Logic // Match Logic
useEffect(() => { useEffect(() => {
if (!pitchData) { if (!pitchData) {
@@ -213,43 +258,13 @@ function App() {
} else { } else {
const duration = Date.now() - matchStartTime; const duration = Date.now() - matchStartTime;
if (duration > NOTE_MATCH_THRESHOLD_MS) { if (duration > NOTE_MATCH_THRESHOLD_MS) {
// Success! handleMatchSuccess();
setFeedbackMessage("Good!");
setRevealed(true);
setMatchStartTime(null);
const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance;
const isTimerMode = settings.rhythm.mode === 'seconds';
if (!isRhythmActive) {
// Standard or Rhythm-Manual
setTimeout(() => {
generateNewNote();
}, 800);
} else {
// Rhythm Active AND Auto-Advance
if (isTimerMode) {
// Dynamic Timer Mode: Success triggers advance
restartMetronome(); // Reset the countdown
setTimeout(() => {
generateNewNote();
}, 200);
setMatchStartTime(null);
} else {
// Strict BPM Mode: Consumed success, but wait for tick.
if (feedbackMessage !== "Good!") { // Only increment if not already good
}
}
}
} }
} }
} else { } else {
setMatchStartTime(null); setMatchStartTime(null);
} }
}, [pitchData, targetMidi, matchStartTime, generateNewNote, settings.rhythm, restartMetronome, feedbackMessage]); }, [pitchData, targetMidi, matchStartTime, feedbackMessage, handleMatchSuccess]);
/* Keyboard Shortcuts */ /* Keyboard Shortcuts */
const [showHelp, setShowHelp] = useState(false); const [showHelp, setShowHelp] = useState(false);
@@ -300,11 +315,25 @@ function App() {
// Hint text construction // Hint text construction
const hintPositions = useMemo(() => { const hintPositions = useMemo(() => {
// If showing full hint OR detecting via fretboard, we need positions?
// Actually, showHint=true renders the red dots.
// If showFretboard=true but showHint=false, we render empty board (interactive).
if (!settings.showHint && !settings.showFretboard) return [];
if (!currentInstrumentDef.showTuning || !currentTuning) return [];
// If hint is hidden but board is explicit, we still need data if we want to support 'hint-on-hover' or similar later.
// But for now, if settings.showHint is FALSE, we pass EMPTY positions to Fretboard so it doesn't draw dots,
// UNLESS we want to decouple 'positions' prop from 'showHint' prop in the component.
// It's cleaner to pass the positions regardless and let the component decide based on a prop,
// OR filter here.
// The request says: "If hints are enabled simultaneously, they can be shown on the same fretboard"
// So if showHint is true -> pass positions. If false -> pass empty.
if (!settings.showHint) return []; if (!settings.showHint) return [];
if (!currentInstrumentDef.showTuning || !currentTuning) return []; // No fretboard hints for piano/voice
return getFretboardPositions(targetMidi, currentTuning); return getFretboardPositions(targetMidi, currentTuning);
}, [settings.showHint, targetMidi, currentTuning, currentInstrumentDef]); }, [settings.showHint, settings.showFretboard, targetMidi, currentTuning, currentInstrumentDef]);
// Auto-enable mic when tuner is turned on // Auto-enable mic when tuner is turned on
useEffect(() => { useEffect(() => {
@@ -377,13 +406,21 @@ function App() {
{settings.showHint && ( {(settings.showHint || settings.showFretboard) && (
<div className="hint-card"> <div className="hint-card">
<div className="hint-note landscape-hint-note"> {settings.showHint && (
{getNoteDetails(targetMidi + currentInstrumentDef.transpose).scientific} <div className="hint-note landscape-hint-note">
</div> {getNoteDetails(targetMidi + currentInstrumentDef.transpose).scientific}
</div>
)}
{currentInstrumentDef.showTuning && currentTuning && ( {currentInstrumentDef.showTuning && currentTuning && (
<FretboardHint tuning={currentTuning} positions={hintPositions} /> <Fretboard
tuning={currentTuning}
positions={hintPositions}
interactive={settings.showFretboard}
onPlayNote={handleVirtualGuitarPlay}
showHints={settings.showHint}
/>
)} )}
</div> </div>
)} )}
@@ -401,6 +438,15 @@ function App() {
{settings.showHint ? "Hide Hint" : "Show Hint"} {settings.showHint ? "Hide Hint" : "Show Hint"}
</button> </button>
<button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title="Toggle Virtual Guitar"
>
<Guitar size={18} />
Guitar
</button>
<button <button
className="hint-button" className="hint-button"
onClick={() => playNote(targetMidi, 1.0)} onClick={() => playNote(targetMidi, 1.0)}

View File

@@ -1,4 +1,4 @@
import { Settings, Guitar, Music, Gauge } from 'lucide-react'; import { Settings, Guitar, Music, Gauge, Volume2 } from 'lucide-react';
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
import { getTempoMarking } from '../music/TempoMarkings'; import { getTempoMarking } from '../music/TempoMarkings';
@@ -19,6 +19,7 @@ export interface RhythmSettings {
export interface AppSettings { export interface AppSettings {
difficulty: Difficulty; difficulty: Difficulty;
showHint: boolean; showHint: boolean;
showFretboard: boolean;
tuningId: string; tuningId: string;
keySignature: string; keySignature: string;
instrument: string; instrument: string;
@@ -29,6 +30,7 @@ export interface AppSettings {
customMinFret?: number; customMinFret?: number;
customMaxFret?: number; customMaxFret?: number;
autoPlaySightReading?: boolean; autoPlaySightReading?: boolean;
autoPlayVolume?: number;
} }
interface ControlsProps { interface ControlsProps {
@@ -354,24 +356,42 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings,
</div> </div>
{/* Tool 3: Auto-play */} {/* Tool 3: Auto-play */}
{settings.gameMode === 'sight_reading' ? ( {/* Tool 3: Auto-play */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label" style={{ marginBottom: 0, textTransform: 'none' }}> <label className="control-label" style={{ marginBottom: 0, textTransform: 'none' }}>
<span>Auto-play</span> <span>Auto-play</span>
</label> </label>
<button <button
className={`switch-button ${settings.autoPlaySightReading ? 'active' : ''}`} className={`switch-button ${settings.gameMode === 'ear_training' || settings.autoPlaySightReading ? 'active' : ''}`}
onClick={() => onUpdateSettings({ ...settings, autoPlaySightReading: !settings.autoPlaySightReading })} onClick={() => {
style={{ transform: 'scale(1)' }} /* Reset scale for consistency */ if (settings.gameMode !== 'ear_training') {
> onUpdateSettings({ ...settings, autoPlaySightReading: !settings.autoPlaySightReading });
<div className="switch-thumb" /> }
</button> }}
</div> disabled={settings.gameMode === 'ear_training'}
style={{ cursor: settings.gameMode === 'ear_training' ? 'not-allowed' : 'pointer', opacity: settings.gameMode === 'ear_training' ? 0.8 : 1 }}
title={settings.gameMode === 'ear_training' ? "Forced On in Ear Training" : "Toggle Auto-play"}
>
<div className="switch-thumb" />
</button>
</div> </div>
) : (
<div /> /* Empty placeholder for grid 3rd column if not sight reading? Or just 2 cols? */ {(settings.gameMode === 'ear_training' || settings.autoPlaySightReading) && (
)} <div style={{ display: 'flex', alignItems: 'center', gap: '4px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<Volume2 size={14} style={{ opacity: 0.7 }} />
<input
type="range"
min="0"
max="1"
step="0.05"
value={settings.autoPlayVolume ?? 0.5}
onChange={(e) => onUpdateSettings({ ...settings, autoPlayVolume: parseFloat(e.target.value) })}
style={{ flex: 1 }}
/>
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,12 +1,22 @@
import type { Tuning, FretPosition } from '../music/Tunings'; import type { Tuning, FretPosition } from '../music/Tunings';
interface FretboardHintProps { interface FretboardProps {
tuning: Tuning; tuning: Tuning;
positions: FretPosition[]; positions: FretPosition[];
maxFrets?: number; // How many frets to display maxFrets?: number;
interactive?: boolean;
showHints?: boolean;
onPlayNote?: (midi: number) => void;
} }
export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHintProps) { export function Fretboard({
tuning,
positions,
maxFrets = 15,
interactive = false,
showHints = true,
onPlayNote
}: FretboardProps) {
// Config // Config
const numStrings = tuning.strings.length; const numStrings = tuning.strings.length;
// Visual params // Visual params
@@ -24,23 +34,18 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin
const markers = [3, 5, 7, 9, 12, 15, 17, 19, 21].filter(m => m <= maxFrets); const markers = [3, 5, 7, 9, 12, 15, 17, 19, 21].filter(m => m <= maxFrets);
// Helper to get Y coordinate for a string index // Helper to get Y coordinate for a string index
// String 0 is lowest pitch. In tab/charts, lowest pitch is usually the BOTTOM line. // String 0 is lowest pitch (Bottom line)
// So index 0 -> max Y. // String N is highest pitch (Top line)
const getStringY = (stringIndex: number) => { const getStringY = (stringIndex: number) => {
// stringIndex 0 (Low E) -> bottom line
// stringIndex N (High E) -> top line
return height - paddingY - (stringIndex * stringSpacing); return height - paddingY - (stringIndex * stringSpacing);
}; };
// Helper to get X coordinate for a fret line // Helper to get X coordinate for a fret line
// Fret N line is at x = padding + nut + N * width
const getFretLineX = (fretNum: number) => { const getFretLineX = (fretNum: number) => {
return paddingX + nutWidth + (fretNum * fretWidth); return paddingX + nutWidth + (fretNum * fretWidth);
}; };
// Helper to get Center X for a note at fret N // Helper to get Center X for a note at fret N
// If fret 0 (open), place it to left of nut.
// If fret > 0, place it in middle of Fret N-1 and Fret N.
const getNoteX = (fretNum: number) => { const getNoteX = (fretNum: number) => {
if (fretNum === 0) { if (fretNum === 0) {
return paddingX + (nutWidth / 2) - 15; // To the left of nut return paddingX + (nutWidth / 2) - 15; // To the left of nut
@@ -50,13 +55,19 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin
return (startX + endX) / 2; return (startX + endX) / 2;
}; };
const handleFretClick = (stringIndex: number, fret: number) => {
if (!interactive || !onPlayNote) return;
const baseMidi = tuning.strings[stringIndex];
const noteMidi = baseMidi + fret;
onPlayNote(noteMidi);
};
return ( return (
<div className="fretboard-container" style={{ overflowX: 'auto', maxWidth: '100%' }}> <div className="fretboard-container" style={{ overflowX: 'auto', maxWidth: '100%', cursor: interactive ? 'pointer' : 'default' }}>
<svg <svg
width="100%" width={width}
height="100%" height={height}
viewBox={`0 0 ${width} ${height}`} style={{ display: 'block', margin: '0 auto' }}
style={{ display: 'block', margin: '0 auto', maxHeight: '100%' }}
> >
{/* Fretboard background */} {/* Fretboard background */}
<rect <rect
@@ -64,7 +75,7 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin
y={paddingY} y={paddingY}
width={width - (paddingX * 2) - nutWidth} width={width - (paddingX * 2) - nutWidth}
height={height - (paddingY * 2)} height={height - (paddingY * 2)}
fill="#999999" // Requested dark gray fill="#999999"
stroke="none" stroke="none"
/> />
@@ -74,17 +85,15 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin
y={paddingY} y={paddingY}
width={nutWidth} width={nutWidth}
height={height - (paddingY * 2)} height={height - (paddingY * 2)}
fill="#333" // Dark nut fill="#333"
/> />
{/* Fret Markers (Dots) on fretboard */} {/* Fret Markers (Dots) */}
{markers.map(m => { {markers.map(m => {
const cx = getNoteX(m); const cx = getNoteX(m);
const cy = height / 2;
const isDouble = m % 12 === 0; const isDouble = m % 12 === 0;
if (isDouble) { if (isDouble) {
// Draw two dots
return ( return (
<g key={`marker-${m}`}> <g key={`marker-${m}`}>
<circle cx={cx} cy={height / 2 - stringSpacing} r={6} fill="#777" /> <circle cx={cx} cy={height / 2 - stringSpacing} r={6} fill="#777" />
@@ -92,15 +101,14 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin
</g> </g>
); );
} }
return ( return (
<circle key={`marker-${m}`} cx={cx} cy={cy} r={6} fill="#777" /> <circle key={`marker-${m}`} cx={cx} cy={height / 2} r={6} fill="#777" />
); );
})} })}
{/* Frets (Vertical lines) */} {/* Frets (Vertical lines) */}
{Array.from({ length: maxFrets + 1 }).map((_, i) => { {Array.from({ length: maxFrets + 1 }).map((_, i) => {
if (i === 0) return null; // Nut handles 0 if (i === 0) return null;
const x = getFretLineX(i); const x = getFretLineX(i);
return ( return (
<line <line
@@ -109,7 +117,7 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin
y1={paddingY} y1={paddingY}
x2={x} x2={x}
y2={height - paddingY} y2={height - paddingY}
stroke="#CCCCCC" // Lighter frets stroke="#CCCCCC"
strokeWidth={1} strokeWidth={1}
/> />
); );
@@ -118,11 +126,7 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin
{/* Strings (Horizontal lines) */} {/* Strings (Horizontal lines) */}
{tuning.strings.map((_, i) => { {tuning.strings.map((_, i) => {
const y = getStringY(i); const y = getStringY(i);
const visualThickness = 4 - (i * 0.4);
// i=0 is LOW pitch (thickest).
// Bolder: Increase base thick
const visualThickness = 4 - (i * 0.4); // 4 down to ~2
return ( return (
<line <line
key={`string-${i}`} key={`string-${i}`}
@@ -130,22 +134,21 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin
y1={y} y1={y}
x2={width - paddingX} x2={width - paddingX}
y2={y} y2={y}
stroke="#EEEEEE" // Lighter strings stroke="#EEEEEE"
strokeWidth={Math.max(1.5, visualThickness)} strokeWidth={Math.max(1.5, visualThickness)}
/> />
); );
})} })}
{/* Target Note Positions */} {/* Target Note Positions (Hints) */}
{positions.map((p, idx) => { {showHints && positions.map((p, idx) => {
// Verify if fret is within range
if (p.fret > maxFrets) return null; if (p.fret > maxFrets) return null;
const cx = getNoteX(p.fret); const cx = getNoteX(p.fret);
const cy = getStringY(p.stringIndex); const cy = getStringY(p.stringIndex);
return ( return (
<g key={`pos-${idx}`}> <g key={`pos-${idx}`} style={{ pointerEvents: 'none' }}>
{/* Add pointerEvents: none so hints don't block clicks if they overlay logic */}
<circle <circle
cx={cx} cx={cx}
cy={cy} cy={cy}
@@ -168,6 +171,47 @@ export function FretboardHint({ tuning, positions, maxFrets = 15 }: FretboardHin
</g> </g>
); );
})} })}
{/* Interaction Overlay (Invisible hit targets) */}
{interactive && tuning.strings.map((_, stringIndex) => {
const y = getStringY(stringIndex);
// Hit area height (centered on string)
const hitHeight = stringSpacing; // Full coverage between strings
const yStart = y - (hitHeight / 2);
return Array.from({ length: maxFrets + 1 }).map((_, fret) => {
// Calculate X range for this fret
let xStart = 0;
let xEnd = 0;
if (fret === 0) {
xStart = paddingX - 25; // Extend left a bit
xEnd = paddingX + nutWidth;
} else {
xStart = getFretLineX(fret - 1);
xEnd = getFretLineX(fret);
}
const rectWidth = xEnd - xStart;
return (
<rect
key={`hit-${stringIndex}-${fret}`}
x={xStart}
y={yStart}
width={rectWidth}
height={hitHeight}
fill="transparent"
style={{ cursor: 'pointer' }}
onClick={() => handleFretClick(stringIndex, fret)}
// Hover effect could be added here via CSS class if we want
className="fret-hit-target"
>
<title>String {stringIndex + 1}, Fret {fret}</title>
</rect>
);
});
})}
</svg> </svg>
</div> </div>
); );