mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-09-02 09:34:33 +02:00
722 lines
26 KiB
TypeScript
722 lines
26 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
import { Logo } from './components/Logo';
|
|
import { LandscapeSuggestion } from './components/LandscapeSuggestion';
|
|
|
|
|
|
import { SheetMusic } from './components/SheetMusic';
|
|
import { Controls, type AppSettings } from './components/Controls';
|
|
import { SettingsModal } from './components/SettingsModal';
|
|
import { OpenSourceModal } from './components/OpenSourceModal';
|
|
import { usePitchDetector } from './hooks/usePitchDetector';
|
|
import { useMetronome } from './hooks/useMetronome';
|
|
import { useAudioPlayer } from './hooks/useAudioPlayer';
|
|
import {
|
|
getRandomNote,
|
|
getNoteDetails
|
|
} from './music/NoteUtils';
|
|
import {
|
|
TUNINGS,
|
|
getFretboardPositions,
|
|
getOpenStringNotes,
|
|
getFirstPositionNotes,
|
|
} 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, Maximize, Minimize } from 'lucide-react';
|
|
import './App.css';
|
|
import './styles/skip-button.css';
|
|
|
|
const NOTE_MATCH_THRESHOLD_MS = 300; // How long to convert hold note to confirm
|
|
|
|
function App() {
|
|
const [listening, setListening] = useState(false);
|
|
const { playNote } = useAudioPlayer();
|
|
|
|
const [targetMidi, setTargetMidi] = useState<number>(60); // Start with C4
|
|
const [settings, setSettings] = useState<AppSettings>({
|
|
difficulty: 'first_pos',
|
|
showHint: false,
|
|
showFretboard: false,
|
|
showTuningMeter: false,
|
|
tuningId: 'standard',
|
|
keySignature: 'C',
|
|
instrument: 'guitar',
|
|
rhythm: {
|
|
mode: 'bpm',
|
|
bpm: 60,
|
|
seconds: 5,
|
|
active: false,
|
|
autoAdvance: false,
|
|
sound: true,
|
|
volume: 0.5
|
|
},
|
|
zenMode: false,
|
|
gameMode: 'sight_reading',
|
|
customMinFret: 0,
|
|
customMaxFret: 12,
|
|
autoPlaySightReading: false,
|
|
autoPlayVolume: 0.5,
|
|
virtualGuitarVolume: 0.5,
|
|
virtualGuitarMute: false,
|
|
micSensitivity: 0.5,
|
|
disableAnimation: false
|
|
});
|
|
|
|
const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(listening, settings.micSensitivity);
|
|
|
|
const [matchStartTime, setMatchStartTime] = useState<number | null>(null);
|
|
const [feedbackMessage, setFeedbackMessage] = useState<string>("");
|
|
const [revealed, setRevealed] = useState(false);
|
|
const [virtualNote, setVirtualNote] = useState<number | null>(null);
|
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
|
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
|
|
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
|
|
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const handleFullscreenChange = () => {
|
|
setIsFullscreen(!!document.fullscreenElement);
|
|
};
|
|
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
|
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
|
}, []);
|
|
|
|
const toggleFullscreen = useCallback(async () => {
|
|
try {
|
|
if (!document.fullscreenElement) {
|
|
await document.documentElement.requestFullscreen();
|
|
} else {
|
|
await document.exitFullscreen();
|
|
}
|
|
} catch (err) {
|
|
console.error("Error toggling fullscreen:", err);
|
|
}
|
|
}, []);
|
|
|
|
const currentTuning = TUNINGS[settings.tuningId];
|
|
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
|
|
|
// Resolve overrides
|
|
const currentRangeDef = useMemo(() => {
|
|
return currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
|
}, [currentInstrumentDef, settings.difficulty]);
|
|
|
|
const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode;
|
|
const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose;
|
|
|
|
// Generate valid notes based on difficulty
|
|
const validNotes = useMemo(() => {
|
|
// Find range config
|
|
const rangeConfig = currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
|
|
|
const getNotesFromConfig = (config: typeof rangeConfig) => {
|
|
if (!config) return [];
|
|
|
|
// Dynamic logic based on type
|
|
if (config.type === 'open_strings') {
|
|
// Use current tuning if applicable (Guitar/Bass)
|
|
if (currentTuning) {
|
|
return getOpenStringNotes(currentTuning);
|
|
}
|
|
// Fallback for non-fretted if they happen to use this type (unlikely)
|
|
return config.notes || [];
|
|
}
|
|
|
|
if (config.type === 'first_position') {
|
|
if (currentTuning) {
|
|
return getFirstPositionNotes(currentTuning);
|
|
}
|
|
return config.notes || [];
|
|
}
|
|
|
|
if (config.type === 'custom_fret') {
|
|
if (currentTuning) {
|
|
const minFret = settings.customMinFret ?? config.defaultMinFret ?? 0;
|
|
const maxFret = settings.customMaxFret ?? config.defaultMaxFret ?? 12;
|
|
|
|
const notes = new Set<number>();
|
|
currentTuning.strings.forEach(stringMidi => {
|
|
for (let fret = minFret; fret <= maxFret; fret++) {
|
|
notes.add(stringMidi + fret);
|
|
}
|
|
});
|
|
return Array.from(notes).sort((a, b) => a - b);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
if (config.type === 'specific_string') {
|
|
if (currentTuning && config.stringIndex !== undefined) {
|
|
const openNote = currentTuning.strings[config.stringIndex];
|
|
if (openNote === undefined) return [];
|
|
|
|
// Generate frets 0 to 12 for this string
|
|
const notes = [];
|
|
for (let i = 0; i <= 12; i++) {
|
|
notes.push(openNote + i);
|
|
}
|
|
return notes;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
// Static fallback
|
|
if (config.notes) return config.notes;
|
|
if (config.min !== undefined && config.max !== undefined) {
|
|
return Array.from({ length: config.max - config.min + 1 }, (_, i) => config.min! + i);
|
|
}
|
|
return [];
|
|
};
|
|
|
|
if (rangeConfig) {
|
|
return getNotesFromConfig(rangeConfig);
|
|
}
|
|
|
|
// Fallback if difficulty ID doesn't match current instrument (e.g. after switch)
|
|
// Return first range of current instrument
|
|
const fallbackRange = currentInstrumentDef.ranges[0];
|
|
return getNotesFromConfig(fallbackRange);
|
|
|
|
}, [settings.difficulty, currentInstrumentDef, currentTuning, settings.customMinFret, settings.customMaxFret]);
|
|
|
|
const generateNewNote = useCallback((keepFeedback = false) => {
|
|
// Determine min/max based on available notes to avoid infinite loops if validNotes empty
|
|
if (validNotes.length === 0) return;
|
|
const min = validNotes[0];
|
|
const max = validNotes[validNotes.length - 1];
|
|
|
|
const newNote = getRandomNote(min, max, validNotes);
|
|
if (newNote === targetMidi && validNotes.length > 1) {
|
|
// Try once to get a different note
|
|
const retry = getRandomNote(min, max, validNotes);
|
|
setTargetMidi(retry);
|
|
} else {
|
|
setTargetMidi(newNote);
|
|
}
|
|
setMatchStartTime(null);
|
|
if (!keepFeedback) {
|
|
setFeedbackMessage("");
|
|
}
|
|
setRevealed(false);
|
|
}, [validNotes, targetMidi]);
|
|
|
|
// Audio Playback trigger
|
|
useEffect(() => {
|
|
const shouldAutoPlay = settings.gameMode === 'ear_training' || (settings.gameMode === 'sight_reading' && settings.autoPlaySightReading);
|
|
|
|
if (shouldAutoPlay && !revealed) {
|
|
// Add a small delay to ensure state settles or allow UI to update
|
|
const timer = setTimeout(() => {
|
|
playNote(targetMidi, 1.0, settings.autoPlayVolume ?? 0.5); // Play for 1 second
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [targetMidi, settings.gameMode, settings.autoPlaySightReading, revealed, playNote]);
|
|
|
|
// Initial note, and whenever difficulty/tuning/gamemode changes
|
|
useEffect(() => {
|
|
generateNewNote();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [settings.difficulty, settings.tuningId, settings.gameMode]);
|
|
|
|
|
|
// Metronome Logic
|
|
// Calculate effective BPM based on mode
|
|
const effectiveBpm = useMemo(() => {
|
|
if (settings.rhythm.mode === 'bpm') return settings.rhythm.bpm;
|
|
// In seconds mode, BPM = 60 / seconds
|
|
return 60 / settings.rhythm.seconds;
|
|
}, [settings.rhythm.mode, settings.rhythm.bpm, settings.rhythm.seconds]);
|
|
|
|
const handleTick = useCallback(() => {
|
|
if (settings.rhythm.autoAdvance && settings.rhythm.active) {
|
|
generateNewNote();
|
|
// Reset feedback message on auto-tick
|
|
setFeedbackMessage("");
|
|
}
|
|
}, [settings.rhythm.autoAdvance, settings.rhythm.active, generateNewNote]);
|
|
|
|
const { restart: restartMetronome } = useMetronome({
|
|
bpm: effectiveBpm,
|
|
volume: settings.rhythm.sound ? settings.rhythm.volume : 0,
|
|
playing: settings.rhythm.active,
|
|
onTick: handleTick
|
|
});
|
|
|
|
|
|
// Common success handler
|
|
const handleMatchSuccess = useCallback(() => {
|
|
// Success!
|
|
if (feedbackTimeoutRef.current) {
|
|
clearTimeout(feedbackTimeoutRef.current);
|
|
}
|
|
|
|
const noteDetails = getNoteDetails(targetMidi);
|
|
setFeedbackMessage(`Good! ${noteDetails.name}`);
|
|
setRevealed(true);
|
|
setMatchStartTime(null);
|
|
|
|
const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance;
|
|
const isTimerMode = settings.rhythm.mode === 'seconds';
|
|
|
|
if (!isRhythmActive) {
|
|
// Standard or Rhythm-Manual
|
|
// Immediate transition
|
|
generateNewNote(true); // Keep "Good!" message
|
|
|
|
if (settings.disableAnimation) {
|
|
setFeedbackMessage("");
|
|
setRevealed(false);
|
|
} else {
|
|
// Allow visual feedback to persist for a moment before clearing text
|
|
feedbackTimeoutRef.current = setTimeout(() => {
|
|
setFeedbackMessage("");
|
|
}, 1500);
|
|
}
|
|
|
|
} else {
|
|
// Rhythm Active AND Auto-Advance
|
|
if (isTimerMode) {
|
|
// Dynamic Timer Mode: Success triggers advance
|
|
restartMetronome(); // Reset the countdown
|
|
// Immediate transition here too?
|
|
generateNewNote(true);
|
|
feedbackTimeoutRef.current = setTimeout(() => {
|
|
setFeedbackMessage("");
|
|
}, 1500);
|
|
|
|
setMatchStartTime(null);
|
|
} else {
|
|
// Strict BPM Mode: Consumed success, but wait for tick.
|
|
// Logic handled by tick
|
|
}
|
|
}
|
|
}, [targetMidi, settings.rhythm, settings.disableAnimation, restartMetronome, generateNewNote]);
|
|
|
|
// Virtual Instrument Handler (Guitar or Piano)
|
|
const handleVirtualInstrumentPlay = useCallback((playedMidi: number) => {
|
|
if (!settings.virtualGuitarMute) {
|
|
playNote(playedMidi, 0.5, settings.virtualGuitarVolume ?? 0.5); // Feedback sound
|
|
}
|
|
setVirtualNote(playedMidi);
|
|
|
|
// Clear the note visualization after a short delay
|
|
setTimeout(() => {
|
|
setVirtualNote(null);
|
|
}, 500);
|
|
|
|
if (playedMidi === targetMidi) {
|
|
// Instant match
|
|
handleMatchSuccess();
|
|
}
|
|
}, [playNote, targetMidi, feedbackMessage, handleMatchSuccess, settings]);
|
|
|
|
// Match Logic
|
|
useEffect(() => {
|
|
if (!pitchData) {
|
|
setMatchStartTime(null);
|
|
return;
|
|
}
|
|
|
|
if (pitchData.midi === targetMidi) {
|
|
|
|
|
|
if (matchStartTime === null) {
|
|
setMatchStartTime(Date.now());
|
|
} else {
|
|
const duration = Date.now() - matchStartTime;
|
|
if (duration > NOTE_MATCH_THRESHOLD_MS) {
|
|
handleMatchSuccess();
|
|
}
|
|
}
|
|
} else {
|
|
setMatchStartTime(null);
|
|
}
|
|
}, [pitchData, targetMidi, matchStartTime, feedbackMessage, handleMatchSuccess]);
|
|
|
|
/* Keyboard Shortcuts */
|
|
const [showHelp, setShowHelp] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
// Ignore if typing in an input
|
|
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
|
|
|
// prevent default for space to stop scrolling
|
|
if (e.code === 'Space') {
|
|
e.preventDefault();
|
|
}
|
|
|
|
switch (e.key.toLowerCase()) {
|
|
case 'h':
|
|
setSettings(s => ({ ...s, showHint: !s.showHint }));
|
|
break;
|
|
case 'z':
|
|
setSettings(s => ({ ...s, zenMode: !s.zenMode }));
|
|
break;
|
|
case 'r': // Replay
|
|
case 'p': // Play
|
|
playNote(targetMidi, 1.0);
|
|
break;
|
|
case 'm': // Toggle Game Mode
|
|
setSettings(s => ({
|
|
...s,
|
|
gameMode: s.gameMode === 'sight_reading' ? 'ear_training' : 'sight_reading'
|
|
}));
|
|
break;
|
|
case 'v': // Virtual Instrument
|
|
setSettings(s => ({ ...s, showFretboard: !s.showFretboard }));
|
|
break;
|
|
case 'l': // Listening (Mic)
|
|
setListening(l => !l);
|
|
break;
|
|
}
|
|
|
|
// Check non-character keys via code to avoid layout issues for function keys
|
|
if (e.code === 'Space' || e.code === 'Enter') {
|
|
generateNewNote();
|
|
}
|
|
|
|
if (e.code === 'Escape') {
|
|
if (showHelp) setShowHelp(false);
|
|
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [settings.zenMode, showHelp, generateNewNote, playNote, targetMidi]);
|
|
|
|
// Hint text construction
|
|
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 [];
|
|
|
|
return getFretboardPositions(targetMidi, currentTuning);
|
|
}, [settings.showHint, settings.showFretboard, targetMidi, currentTuning, currentInstrumentDef]);
|
|
|
|
// Auto-enable mic when tuner is turned on
|
|
useEffect(() => {
|
|
if (settings.showTuningMeter && !listening) {
|
|
setListening(true);
|
|
}
|
|
}, [settings.showTuningMeter, listening]);
|
|
|
|
return (
|
|
<div className={`app-container ${settings.zenMode ? 'zen-mode' : ''}`}>
|
|
<LandscapeSuggestion />
|
|
{!settings.zenMode && (
|
|
<header className="app-header">
|
|
<div className="app-header-left">
|
|
<Logo className="logo" />
|
|
</div>
|
|
|
|
<div className="header-controls">
|
|
<div className="game-mode-toggle">
|
|
<button
|
|
className={`toggle-option ${settings.gameMode === 'sight_reading' ? 'active' : ''}`}
|
|
onClick={() => setSettings(s => ({ ...s, gameMode: 'sight_reading' }))}
|
|
>
|
|
Sight Reading
|
|
</button>
|
|
<button
|
|
className={`toggle-option ${settings.gameMode === 'ear_training' ? 'active' : ''}`}
|
|
onClick={() => setSettings(s => ({ ...s, gameMode: 'ear_training' }))}
|
|
>
|
|
Ear Training
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
className="icon-button"
|
|
onClick={toggleFullscreen}
|
|
title="Toggle Fullscreen"
|
|
>
|
|
{isFullscreen ? <Minimize size={24} /> : <Maximize size={24} />}
|
|
</button>
|
|
<button
|
|
className="icon-button"
|
|
onClick={() => setSettings(s => ({ ...s, zenMode: !s.zenMode }))}
|
|
title="Enter Zen Mode (Z)"
|
|
>
|
|
<Maximize size={24} style={{ transform: 'rotate(45deg)' }} />
|
|
</button>
|
|
<button
|
|
className="icon-button help-btn"
|
|
onClick={() => setShowHelp(true)}
|
|
title="Shortcuts Help"
|
|
>
|
|
<HelpCircle size={24} />
|
|
</button>
|
|
<button
|
|
className="icon-button"
|
|
onClick={() => setIsSettingsOpen(true)}
|
|
title="Settings"
|
|
>
|
|
<Settings size={24} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
)}
|
|
|
|
<main className="main-stage">
|
|
<div className={`card sheet-music-card ${(settings.showHint || settings.showFretboard) ? 'has-hint' : ''} ${settings.zenMode ? 'zen-mode' : ''}`}>
|
|
<div className="sheet-music-container">
|
|
<SheetMusic
|
|
targetMidi={targetMidi}
|
|
playedMidi={virtualNote ?? pitchData?.midi}
|
|
keySignature={settings.keySignature}
|
|
clef={activeClef}
|
|
transpose={activeTranspose}
|
|
width={Math.min(window.innerWidth - 40, 500)}
|
|
height={activeClef === 'grand' ? 260 : 180}
|
|
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
|
|
hoverMidi={settings.showHint ? hoveredMidi : null}
|
|
/>
|
|
</div>
|
|
|
|
{!settings.zenMode && (
|
|
<div className="feedback-area">
|
|
{feedbackMessage ? (
|
|
<div key={feedbackMessage} className="success-message animate-pop">
|
|
{feedbackMessage.startsWith("Good! ") ? (
|
|
<>
|
|
<div className="success-prefix">Good!</div>
|
|
<div className="success-note">{feedbackMessage.replace("Good! ", "")}</div>
|
|
</>
|
|
) : (
|
|
feedbackMessage
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="instruction-text">
|
|
{settings.gameMode === 'ear_training' ? "Listen and play the note" : "Play the note above"}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
|
|
|
|
{(settings.showHint || settings.showFretboard) && (
|
|
<div className="hint-card">
|
|
{settings.showHint && (
|
|
<div className="hint-note landscape-hint-note">
|
|
{getNoteDetails(targetMidi + activeTranspose).scientific}
|
|
</div>
|
|
)}
|
|
|
|
{/* Virtual Instrument Display */}
|
|
{currentInstrumentDef.showTuning && (
|
|
currentInstrumentDef.id === 'piano' ? (
|
|
<PianoKeys
|
|
minMidi={36} // C2
|
|
maxMidi={84} // C6
|
|
markedNotes={settings.showHint ? [targetMidi] : []}
|
|
interactive={settings.showFretboard || settings.showHint}
|
|
showTooltips={settings.showHint}
|
|
displayTranspose={activeTranspose}
|
|
onPlayNote={handleVirtualInstrumentPlay}
|
|
onHover={setHoveredMidi}
|
|
/>
|
|
) : (
|
|
currentTuning && (
|
|
<Fretboard
|
|
tuning={currentTuning}
|
|
positions={hintPositions}
|
|
interactive={settings.showFretboard || settings.showHint}
|
|
showTooltips={settings.showHint}
|
|
displayTranspose={activeTranspose}
|
|
onPlayNote={handleVirtualInstrumentPlay}
|
|
onHover={setHoveredMidi}
|
|
showHints={settings.showHint}
|
|
maxFrets={15}
|
|
/>
|
|
)
|
|
)
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{error && <div className="error-message">{error}</div>}
|
|
|
|
{!settings.zenMode && (
|
|
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
|
|
{currentInstrumentDef.showTuning && (
|
|
<button
|
|
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
|
|
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
|
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
|
>
|
|
<Guitar size={18} />
|
|
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
|
</button>
|
|
)}
|
|
|
|
<button
|
|
className={`hint-button ${settings.showHint ? 'active' : ''}`}
|
|
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
|
title="Keyboard Shortcut: H"
|
|
>
|
|
<HelpCircle size={18} />
|
|
{settings.showHint ? "Hide Hint" : "Show Hint"}
|
|
</button>
|
|
|
|
<button
|
|
className="hint-button"
|
|
onClick={() => playNote(targetMidi, 1.0)}
|
|
title="Keyboard Shortcut: P or R"
|
|
>
|
|
<Volume2 size={18} />
|
|
Play Note
|
|
</button>
|
|
|
|
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
|
|
<SkipForward size={18} />
|
|
Skip Note
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
|
|
{
|
|
!settings.zenMode && (
|
|
<div className="pitch-monitor-bar">
|
|
<button
|
|
className={`mic-button ${listening ? 'listening' : ''}`}
|
|
onClick={() => setListening(!listening)}
|
|
aria-label={listening ? "Stop Listening" : "Start Listening"}
|
|
title="Toggle Monitor (Keyboard Shortcut: L)"
|
|
>
|
|
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
|
</button>
|
|
|
|
<div className={`pitch-readout ${pitchData ? 'active' : ''}`}>
|
|
{pitchData ? (
|
|
<>
|
|
<span className={`detected-note ${pitchData.midi === targetMidi ? 'match' : ''}`}>
|
|
{pitchData.note}
|
|
</span>
|
|
<span className="detected-hz">{Math.round(pitchData.frequency)} Hz</span>
|
|
</>
|
|
) : (
|
|
<span className="placeholder">{listening ? "Listening..." : "Mic Off"}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
</main >
|
|
|
|
{!settings.zenMode && (
|
|
<footer className="settings-footer">
|
|
<Controls
|
|
settings={settings}
|
|
onUpdateSettings={setSettings}
|
|
currentPitch={pitchData ? { note: pitchData.note, cents: pitchData.cents } : null}
|
|
/>
|
|
<div className="app-subtitle">
|
|
<button className="link-button" onClick={() => setIsOpenSourceModalOpen(true)}>Open Source Libraries</button>
|
|
<span className="separator">•</span>
|
|
<a href="https://github.com/9x/sheetmusictrainer" target="_blank" rel="noopener noreferrer">GitHub</a>
|
|
<span className="separator">•</span>
|
|
<a href="http://jensmohrmann.de" target="_blank" rel="noopener noreferrer">JensMohrmann.de</a>
|
|
</div>
|
|
</footer>
|
|
)}
|
|
|
|
|
|
{/* Exit Zen Mode Button - Only shown in Zen Mode */}
|
|
{settings.zenMode && (
|
|
<button
|
|
className="exit-zen-button"
|
|
onClick={() => setSettings(s => ({ ...s, zenMode: false }))}
|
|
title="Keyboard Shortcut: Z"
|
|
>
|
|
Exit Zen Mode
|
|
</button>
|
|
)}
|
|
|
|
{/* Help Popup */}
|
|
{
|
|
showHelp && (
|
|
<div className="help-popup-overlay" onClick={() => setShowHelp(false)}>
|
|
<div className="help-popup" onClick={e => e.stopPropagation()}>
|
|
<button className="help-close" onClick={() => setShowHelp(false)}><X size={20} /></button>
|
|
<h2 style={{ marginTop: 0, marginBottom: '24px' }}>Keyboard Shortcuts</h2>
|
|
|
|
<div className="help-item">
|
|
<span>Skip Note</span>
|
|
<span className="shortcut-key">Space</span>
|
|
</div>
|
|
<div className="help-item">
|
|
<span>Toggle Hint</span>
|
|
<span className="shortcut-key">H</span>
|
|
</div>
|
|
<div className="help-item">
|
|
<span>Toggle Virtual Instrument</span>
|
|
<span className="shortcut-key">V</span>
|
|
</div>
|
|
<div className="help-item">
|
|
<span>Toggle Mic</span>
|
|
<span className="shortcut-key">L</span>
|
|
</div>
|
|
<div className="help-item">
|
|
<span>Toggle Zen Mode</span>
|
|
<span className="shortcut-key">Z</span>
|
|
</div>
|
|
<div className="help-item">
|
|
<span>Replay Note</span>
|
|
<span className="shortcut-key">R</span>
|
|
</div>
|
|
<div className="help-item">
|
|
<span>Toggle Game Mode</span>
|
|
<span className="shortcut-key">M</span>
|
|
</div>
|
|
<div className="help-item">
|
|
<span>Close Help / Exit Zen</span>
|
|
<span className="shortcut-key">Esc</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
<SettingsModal
|
|
isOpen={isSettingsOpen}
|
|
onClose={() => setIsSettingsOpen(false)}
|
|
settings={settings}
|
|
onUpdateSettings={setSettings}
|
|
audioLevel={audioLevel}
|
|
debugInfo={debugInfo}
|
|
isListening={isListening}
|
|
/>
|
|
|
|
<OpenSourceModal
|
|
isOpen={isOpenSourceModalOpen}
|
|
onClose={() => setIsOpenSourceModalOpen(false)}
|
|
/>
|
|
</div >
|
|
);
|
|
}
|
|
|
|
export default App;
|