mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-09-02 09:34:33 +02:00
Virtual guitar
This commit is contained in:
128
src/App.tsx
128
src/App.tsx
@@ -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">
|
||||||
|
{settings.showHint && (
|
||||||
<div className="hint-note landscape-hint-note">
|
<div className="hint-note landscape-hint-note">
|
||||||
{getNoteDetails(targetMidi + currentInstrumentDef.transpose).scientific}
|
{getNoteDetails(targetMidi + currentInstrumentDef.transpose).scientific}
|
||||||
</div>
|
</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)}
|
||||||
|
|||||||
@@ -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,26 +356,44 @@ 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 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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" />
|
<div className="switch-thumb" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(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 /> /* Empty placeholder for grid 3rd column if not sight reading? Or just 2 cols? */
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
Reference in New Issue
Block a user