mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-09-02 09:34:33 +02:00
Refactoring
This commit is contained in:
1164
package-lock.json
generated
1164
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
"test": "vitest",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -18,6 +19,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.1",
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.1",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.5",
|
"@types/react": "^19.2.5",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -26,8 +30,10 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.4",
|
"typescript-eslint": "^8.46.4",
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
297
src/App.tsx
297
src/App.tsx
@@ -1,24 +1,20 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { Logo } from './components/Logo';
|
import { Logo } from './components/Logo';
|
||||||
import { LandscapeSuggestion } from './components/LandscapeSuggestion';
|
import { LandscapeSuggestion } from './components/LandscapeSuggestion';
|
||||||
|
|
||||||
|
|
||||||
import { SheetMusic } from './components/SheetMusic';
|
import { SheetMusic } from './components/SheetMusic';
|
||||||
import { Controls, type AppSettings } from './components/Controls';
|
import { Controls } from './components/Controls';
|
||||||
import { SettingsModal } from './components/SettingsModal';
|
import { SettingsModal } from './components/SettingsModal';
|
||||||
import { OpenSourceModal } from './components/OpenSourceModal';
|
import { OpenSourceModal } from './components/OpenSourceModal';
|
||||||
import { usePitchDetector } from './hooks/usePitchDetector';
|
import { usePitchDetector } from './hooks/usePitchDetector';
|
||||||
import { useMetronome } from './hooks/useMetronome';
|
|
||||||
import { useAudioPlayer } from './hooks/useAudioPlayer';
|
import { useAudioPlayer } from './hooks/useAudioPlayer';
|
||||||
import {
|
import { useSettings } from './context/SettingsContext';
|
||||||
getRandomNote,
|
import { useGameLogic } from './hooks/useGameLogic';
|
||||||
getNoteDetails
|
import { getNoteDetails } from './music/NoteUtils';
|
||||||
} from './music/NoteUtils';
|
|
||||||
import {
|
import {
|
||||||
TUNINGS,
|
TUNINGS,
|
||||||
getFretboardPositions,
|
getFretboardPositions,
|
||||||
getOpenStringNotes,
|
|
||||||
getFirstPositionNotes,
|
|
||||||
} 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';
|
||||||
@@ -28,8 +24,6 @@ import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X, Guitar, Settings, Max
|
|||||||
import './App.css';
|
import './App.css';
|
||||||
import './styles/skip-button.css';
|
import './styles/skip-button.css';
|
||||||
|
|
||||||
import { NOTE_MATCH_THRESHOLD_MS } from './AppConfig';
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -37,47 +31,25 @@ function App() {
|
|||||||
const [listening, setListening] = useState(false);
|
const [listening, setListening] = useState(false);
|
||||||
const { playNote } = useAudioPlayer();
|
const { playNote } = useAudioPlayer();
|
||||||
|
|
||||||
const [targetMidi, setTargetMidi] = useState<number>(60); // Start with C4
|
const { settings, updateSettings } = useSettings();
|
||||||
const [settings, setSettings] = useState<AppSettings>({
|
// Alias to keep existing code working with minimal changes
|
||||||
difficulty: 'first_pos',
|
const setSettings = updateSettings;
|
||||||
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,
|
|
||||||
theme: 'auto'
|
|
||||||
});
|
|
||||||
|
|
||||||
const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(listening, settings.micSensitivity);
|
const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(listening, settings.micSensitivity);
|
||||||
|
|
||||||
const [matchStartTime, setMatchStartTime] = useState<number | null>(null);
|
// Game Logic Hook
|
||||||
const [feedbackMessage, setFeedbackMessage] = useState<string>("");
|
const {
|
||||||
const [revealed, setRevealed] = useState(false);
|
targetMidi,
|
||||||
const [virtualNote, setVirtualNote] = useState<number | null>(null);
|
feedbackMessage,
|
||||||
|
revealed,
|
||||||
|
virtualNote,
|
||||||
|
generateNewNote,
|
||||||
|
handleVirtualInstrumentPlay
|
||||||
|
} = useGameLogic(pitchData);
|
||||||
|
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
|
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
|
||||||
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
|
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
|
||||||
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
|
||||||
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
|
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
|
||||||
@@ -130,239 +102,10 @@ function App() {
|
|||||||
return currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
return currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
||||||
}, [currentInstrumentDef, settings.difficulty]);
|
}, [currentInstrumentDef, settings.difficulty]);
|
||||||
|
|
||||||
|
// Determine active clef/transpose for rendering
|
||||||
const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode;
|
const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode;
|
||||||
const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose;
|
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 */
|
/* Keyboard Shortcuts */
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
@@ -659,8 +402,6 @@ function App() {
|
|||||||
{!settings.zenMode && (
|
{!settings.zenMode && (
|
||||||
<footer className="settings-footer">
|
<footer className="settings-footer">
|
||||||
<Controls
|
<Controls
|
||||||
settings={settings}
|
|
||||||
onUpdateSettings={setSettings}
|
|
||||||
currentPitch={pitchData ? { note: pitchData.note, cents: pitchData.cents } : null}
|
currentPitch={pitchData ? { note: pitchData.note, cents: pitchData.cents } : null}
|
||||||
/>
|
/>
|
||||||
<div className="app-subtitle">
|
<div className="app-subtitle">
|
||||||
|
|||||||
@@ -4,47 +4,19 @@ import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
|||||||
import { getTempoMarking } from '../music/TempoMarkings';
|
import { getTempoMarking } from '../music/TempoMarkings';
|
||||||
import { TuningMeter } from './TuningMeter';
|
import { TuningMeter } from './TuningMeter';
|
||||||
|
|
||||||
export type Difficulty = string;
|
import { useSettings } from '../context/SettingsContext';
|
||||||
|
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes';
|
||||||
|
|
||||||
export interface RhythmSettings {
|
|
||||||
mode: 'bpm' | 'seconds';
|
|
||||||
bpm: number;
|
|
||||||
seconds: number;
|
|
||||||
active: boolean;
|
|
||||||
autoAdvance: boolean;
|
|
||||||
sound: boolean;
|
|
||||||
volume: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppSettings {
|
|
||||||
difficulty: Difficulty;
|
|
||||||
showHint: boolean;
|
|
||||||
showFretboard: boolean;
|
|
||||||
tuningId: string;
|
|
||||||
keySignature: string;
|
|
||||||
instrument: string;
|
|
||||||
showTuningMeter: boolean;
|
|
||||||
rhythm: RhythmSettings;
|
|
||||||
zenMode: boolean;
|
|
||||||
gameMode: 'sight_reading' | 'ear_training';
|
|
||||||
customMinFret?: number;
|
|
||||||
customMaxFret?: number;
|
|
||||||
autoPlaySightReading?: boolean;
|
|
||||||
autoPlayVolume?: number;
|
|
||||||
virtualGuitarVolume?: number;
|
|
||||||
micSensitivity?: number; // 0.0 to 1.0 (0=least sensitive, 1=most)
|
|
||||||
virtualGuitarMute?: boolean;
|
|
||||||
disableAnimation?: boolean;
|
|
||||||
theme?: 'light' | 'dark' | 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ControlsProps {
|
interface ControlsProps {
|
||||||
settings: AppSettings;
|
|
||||||
onUpdateSettings: (s: AppSettings) => void;
|
|
||||||
currentPitch: { note: string; cents: number } | null;
|
currentPitch: { note: string; cents: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings, currentPitch }) => {
|
export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||||
|
const { settings, updateSettings } = useSettings();
|
||||||
|
// Alias to minimize refactor, or just use updateSettings.
|
||||||
|
const onUpdateSettings = updateSettings;
|
||||||
const handleDifficultyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
const handleDifficultyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
onUpdateSettings({ ...settings, difficulty: e.target.value as Difficulty });
|
onUpdateSettings({ ...settings, difficulty: e.target.value as Difficulty });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor } from 'lucide-react';
|
import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor } from 'lucide-react';
|
||||||
import type { AppSettings } from './Controls';
|
import type { AppSettings } from '../types/SettingsTypes';
|
||||||
import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector';
|
import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector';
|
||||||
|
|
||||||
interface SettingsModalProps {
|
interface SettingsModalProps {
|
||||||
|
|||||||
36
src/context/SettingsContext.tsx
Normal file
36
src/context/SettingsContext.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React, { createContext, useContext, useState, type ReactNode } from 'react';
|
||||||
|
import { type AppSettings, DEFAULT_SETTINGS } from '../types/SettingsTypes';
|
||||||
|
|
||||||
|
interface SettingsContextType {
|
||||||
|
settings: AppSettings;
|
||||||
|
updateSettings: (newSettings: AppSettings | ((prev: AppSettings) => AppSettings)) => void;
|
||||||
|
updateSetting: <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||||
|
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
|
const updateSettings = (newSettings: AppSettings | ((prev: AppSettings) => AppSettings)) => {
|
||||||
|
setSettings(newSettings);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
|
||||||
|
setSettings(prev => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsContext.Provider value={{ settings, updateSettings, updateSetting }}>
|
||||||
|
{children}
|
||||||
|
</SettingsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSettings = () => {
|
||||||
|
const context = useContext(SettingsContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useSettings must be used within a SettingsProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
234
src/hooks/useGameLogic.ts
Normal file
234
src/hooks/useGameLogic.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { useSettings } from '../context/SettingsContext';
|
||||||
|
import { useAudioPlayer } from './useAudioPlayer';
|
||||||
|
import { useMetronome } from './useMetronome';
|
||||||
|
import {
|
||||||
|
getRandomNote,
|
||||||
|
getNoteDetails
|
||||||
|
} from '../music/NoteUtils';
|
||||||
|
import {
|
||||||
|
TUNINGS,
|
||||||
|
getOpenStringNotes,
|
||||||
|
getFirstPositionNotes,
|
||||||
|
} from '../music/Tunings';
|
||||||
|
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
||||||
|
import { NOTE_MATCH_THRESHOLD_MS } from '../AppConfig';
|
||||||
|
|
||||||
|
export const useGameLogic = (
|
||||||
|
pitchData: { midi: number; note: string; cents: number; frequency: number } | null
|
||||||
|
) => {
|
||||||
|
const { settings } = useSettings();
|
||||||
|
const { playNote } = useAudioPlayer();
|
||||||
|
|
||||||
|
const [targetMidi, setTargetMidi] = useState<number>(60);
|
||||||
|
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 feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// --- Valid Notes Calculation ---
|
||||||
|
const currentTuning = TUNINGS[settings.tuningId];
|
||||||
|
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||||
|
|
||||||
|
const validNotes = useMemo(() => {
|
||||||
|
const rangeConfig = currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
||||||
|
|
||||||
|
const getNotesFromConfig = (config: typeof rangeConfig) => {
|
||||||
|
if (!config) return [];
|
||||||
|
|
||||||
|
if (config.type === 'open_strings') {
|
||||||
|
if (currentTuning) return getOpenStringNotes(currentTuning);
|
||||||
|
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 [];
|
||||||
|
const notes = [];
|
||||||
|
for (let i = 0; i <= 12; i++) {
|
||||||
|
notes.push(openNote + i);
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
const fallbackRange = currentInstrumentDef.ranges[0];
|
||||||
|
return getNotesFromConfig(fallbackRange);
|
||||||
|
|
||||||
|
}, [settings.difficulty, currentInstrumentDef, currentTuning, settings.customMinFret, settings.customMaxFret]);
|
||||||
|
|
||||||
|
// --- Note Generation ---
|
||||||
|
const generateNewNote = useCallback((keepFeedback = false) => {
|
||||||
|
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) {
|
||||||
|
const retry = getRandomNote(min, max, validNotes);
|
||||||
|
setTargetMidi(retry);
|
||||||
|
} else {
|
||||||
|
setTargetMidi(newNote);
|
||||||
|
}
|
||||||
|
setMatchStartTime(null);
|
||||||
|
if (!keepFeedback) {
|
||||||
|
setFeedbackMessage("");
|
||||||
|
}
|
||||||
|
setRevealed(false);
|
||||||
|
}, [validNotes, targetMidi]);
|
||||||
|
|
||||||
|
// --- Metronome Logic ---
|
||||||
|
const effectiveBpm = useMemo(() => {
|
||||||
|
if (settings.rhythm.mode === 'bpm') return settings.rhythm.bpm;
|
||||||
|
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();
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Audio Auto-Play ---
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldAutoPlay = settings.gameMode === 'ear_training' || (settings.gameMode === 'sight_reading' && settings.autoPlaySightReading);
|
||||||
|
|
||||||
|
if (shouldAutoPlay && !revealed) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
playNote(targetMidi, 1.0, settings.autoPlayVolume ?? 0.5);
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [targetMidi, settings.gameMode, settings.autoPlaySightReading, revealed, playNote, settings.autoPlayVolume]);
|
||||||
|
|
||||||
|
// --- Init / Reset ---
|
||||||
|
useEffect(() => {
|
||||||
|
generateNewNote();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settings.difficulty, settings.tuningId, settings.gameMode]);
|
||||||
|
|
||||||
|
|
||||||
|
// --- Success Handler ---
|
||||||
|
const handleMatchSuccess = useCallback(() => {
|
||||||
|
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) {
|
||||||
|
generateNewNote(true);
|
||||||
|
if (settings.disableAnimation) {
|
||||||
|
setFeedbackMessage("");
|
||||||
|
setRevealed(false);
|
||||||
|
} else {
|
||||||
|
feedbackTimeoutRef.current = setTimeout(() => {
|
||||||
|
setFeedbackMessage("");
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isTimerMode) {
|
||||||
|
restartMetronome();
|
||||||
|
generateNewNote(true);
|
||||||
|
feedbackTimeoutRef.current = setTimeout(() => {
|
||||||
|
setFeedbackMessage("");
|
||||||
|
}, 1500);
|
||||||
|
setMatchStartTime(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [targetMidi, settings.rhythm, settings.disableAnimation, restartMetronome, generateNewNote]);
|
||||||
|
|
||||||
|
// --- Virtual Instrument Handler ---
|
||||||
|
const handleVirtualInstrumentPlay = useCallback((playedMidi: number) => {
|
||||||
|
if (!settings.virtualGuitarMute) {
|
||||||
|
playNote(playedMidi, 0.5, settings.virtualGuitarVolume ?? 0.5);
|
||||||
|
}
|
||||||
|
setVirtualNote(playedMidi);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setVirtualNote(null);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
if (playedMidi === targetMidi) {
|
||||||
|
handleMatchSuccess();
|
||||||
|
}
|
||||||
|
}, [playNote, targetMidi, handleMatchSuccess, settings.virtualGuitarMute, settings.virtualGuitarVolume]);
|
||||||
|
|
||||||
|
|
||||||
|
// --- Match Checking Loop ---
|
||||||
|
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, handleMatchSuccess]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
targetMidi,
|
||||||
|
feedbackMessage,
|
||||||
|
revealed,
|
||||||
|
virtualNote,
|
||||||
|
generateNewNote,
|
||||||
|
handleMatchSuccess,
|
||||||
|
handleVirtualInstrumentPlay,
|
||||||
|
setVirtualNote // Exposed for consistency or additional external control if needed
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
|
|
||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import './styles/variables.css'
|
import './styles/variables.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
import { SettingsProvider } from './context/SettingsContext.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<SettingsProvider>
|
||||||
|
<App />
|
||||||
|
</SettingsProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
119
src/music/NoteUtils.test.ts
Normal file
119
src/music/NoteUtils.test.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
frequencyToMidi,
|
||||||
|
midiToFrequency,
|
||||||
|
getNoteDetails,
|
||||||
|
getRandomNote,
|
||||||
|
getNoteInKey
|
||||||
|
} from './NoteUtils';
|
||||||
|
|
||||||
|
describe('NoteUtils', () => {
|
||||||
|
describe('frequencyToMidi', () => {
|
||||||
|
it('should correctly convert A4 (440Hz) to MIDI 69', () => {
|
||||||
|
expect(frequencyToMidi(440)).toBe(69);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly convert C4 (approx 261.63Hz) to MIDI 60', () => {
|
||||||
|
// C4 is 261.625...
|
||||||
|
expect(frequencyToMidi(261.63)).toBe(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 0 for 0 or negative frequency', () => {
|
||||||
|
expect(frequencyToMidi(0)).toBe(0);
|
||||||
|
expect(frequencyToMidi(-100)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('midiToFrequency', () => {
|
||||||
|
it('should correctly convert MIDI 69 to 440Hz', () => {
|
||||||
|
expect(midiToFrequency(69)).toBe(440);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly convert MIDI 60 to C4 frequency', () => {
|
||||||
|
const freq = midiToFrequency(60);
|
||||||
|
expect(freq).toBeCloseTo(261.625565);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNoteDetails', () => {
|
||||||
|
it('should return correct details for C4 (60)', () => {
|
||||||
|
const details = getNoteDetails(60);
|
||||||
|
expect(details.name).toBe('C');
|
||||||
|
expect(details.octave).toBe(4);
|
||||||
|
expect(details.scientific).toBe('C4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct details for A4 (69)', () => {
|
||||||
|
const details = getNoteDetails(69);
|
||||||
|
expect(details.name).toBe('A');
|
||||||
|
expect(details.octave).toBe(4);
|
||||||
|
expect(details.scientific).toBe('A4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct details for F#4 (66)', () => {
|
||||||
|
const details = getNoteDetails(66);
|
||||||
|
expect(details.name).toBe('F#');
|
||||||
|
expect(details.octave).toBe(4);
|
||||||
|
expect(details.scientific).toBe('F#4');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getRandomNote', () => {
|
||||||
|
it('should return a note within range', () => {
|
||||||
|
for (let i = 0; i < 100; i++) {
|
||||||
|
const note = getRandomNote(60, 72);
|
||||||
|
expect(note).toBeGreaterThanOrEqual(60);
|
||||||
|
expect(note).toBeLessThanOrEqual(72);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify min and max bounds are inclusive', () => {
|
||||||
|
// Statistically probable to hit bounds with enough iterations if range is small
|
||||||
|
const results = new Set<number>();
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
results.add(getRandomNote(60, 61));
|
||||||
|
}
|
||||||
|
expect(results.has(60)).toBe(true);
|
||||||
|
expect(results.has(61)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return a note from validNotes list if provided', () => {
|
||||||
|
const valid = [60, 64, 67]; // C major triad
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const note = getRandomNote(0, 100, valid);
|
||||||
|
expect(valid).toContain(note);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNoteInKey', () => {
|
||||||
|
it('should return simple note for C Major', () => {
|
||||||
|
// C4 in C Major -> c/4
|
||||||
|
const spec = getNoteInKey(60, 'C');
|
||||||
|
expect(spec.keys[0]).toBe('c/4');
|
||||||
|
expect(spec.accidental).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should implies accidental if in key signature (F# in G Major)', () => {
|
||||||
|
// F#4 (66) in G Major (which has F#) -> f#/4, no accidental shown
|
||||||
|
const spec = getNoteInKey(66, 'G');
|
||||||
|
expect(spec.keys[0]).toBe('f#/4');
|
||||||
|
expect(spec.accidental).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show accidental if outside key signature (F# in C Major)', () => {
|
||||||
|
// F#4 (66) in C Major -> f#/4, show #
|
||||||
|
const spec = getNoteInKey(66, 'C');
|
||||||
|
expect(spec.keys[0]).toBe('f#/4');
|
||||||
|
expect(spec.accidental).toBe('#');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show natural if key has accidental but note is natural (F in G Major)', () => {
|
||||||
|
// F4 (65) in G Major (expects F#) -> f/4, show natural
|
||||||
|
const spec = getNoteInKey(65, 'G');
|
||||||
|
// rawName for 65 is F.
|
||||||
|
expect(spec.keys[0]).toBe('f/4'); // "f/4"
|
||||||
|
expect(spec.accidental).toBe('n');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
1
src/test/setup.ts
Normal file
1
src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
63
src/types/SettingsTypes.ts
Normal file
63
src/types/SettingsTypes.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
export type Difficulty = string;
|
||||||
|
|
||||||
|
export interface RhythmSettings {
|
||||||
|
mode: 'bpm' | 'seconds';
|
||||||
|
bpm: number;
|
||||||
|
seconds: number;
|
||||||
|
active: boolean;
|
||||||
|
autoAdvance: boolean;
|
||||||
|
sound: boolean;
|
||||||
|
volume: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
difficulty: Difficulty;
|
||||||
|
showHint: boolean;
|
||||||
|
showFretboard: boolean;
|
||||||
|
showTuningMeter: boolean;
|
||||||
|
tuningId: string;
|
||||||
|
keySignature: string;
|
||||||
|
instrument: string;
|
||||||
|
rhythm: RhythmSettings;
|
||||||
|
zenMode: boolean;
|
||||||
|
gameMode: 'sight_reading' | 'ear_training';
|
||||||
|
customMinFret?: number;
|
||||||
|
customMaxFret?: number;
|
||||||
|
autoPlaySightReading?: boolean;
|
||||||
|
autoPlayVolume?: number;
|
||||||
|
virtualGuitarVolume?: number;
|
||||||
|
virtualGuitarMute?: boolean;
|
||||||
|
micSensitivity?: number;
|
||||||
|
disableAnimation?: boolean;
|
||||||
|
theme?: 'light' | 'dark' | 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: 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,
|
||||||
|
theme: 'auto'
|
||||||
|
};
|
||||||
2
src/vite-env.d.ts
vendored
Normal file
2
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vitest" />
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { defineConfig } from 'vite'
|
/// <reference types="vitest" />
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
@@ -6,4 +7,9 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
// IMPORTANT: Replace 'sheet-music-trainer' with your GitHub repository name
|
// IMPORTANT: Replace 'sheet-music-trainer' with your GitHub repository name
|
||||||
base: '/',
|
base: '/',
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: './src/test/setup.ts',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user