diff --git a/src/App.tsx b/src/App.tsx index 21bc491..4118c07 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useState, useEffect, useLayoutEffect, useCallback, useMemo } from 'react'; import { Logo } from './components/Logo'; import { LandscapeSuggestion } from './components/LandscapeSuggestion'; @@ -9,9 +9,10 @@ import { SettingsModal } from './components/SettingsModal'; import { OpenSourceModal } from './components/OpenSourceModal'; import { usePitchDetector } from './hooks/usePitchDetector'; import { useAudioPlayer } from './hooks/useAudioPlayer'; -import { useSettings } from './context/SettingsContext'; +import { useSettings } from './context/useSettings'; import { useGameLogic } from './hooks/useGameLogic'; -import { getNoteDetails } from './music/NoteUtils'; +import { audioEngine } from './audio/AudioEngine'; +import { getNoteDetails, midiToFrequency } from './music/NoteUtils'; import { TUNINGS, getFretboardPositions, @@ -35,9 +36,30 @@ function App() { // Alias to keep existing code working with minimal changes const setSettings = updateSettings; - const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(listening, settings.micSensitivity); + const currentTuning = TUNINGS[settings.tuningId]; + const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; - // Game Logic Hook + // Lowest note the instrument can produce: the pitch analysis window adapts + // to it (shorter window = lower latency for instruments without deep bass) + const instrumentMinMidi = useMemo(() => { + let min = Infinity; + for (const r of currentInstrumentDef.ranges) { + if (r.min !== undefined) min = Math.min(min, r.min); + } + if (currentTuning) { + for (const stringMidi of currentTuning.strings) min = Math.min(min, stringMidi); + } + return min === Infinity ? 40 : min; // Fallback: guitar low E + }, [currentInstrumentDef, currentTuning]); + + // One semitone of margin below the lowest note (detuned strings, flat playing) + const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector( + listening, + settings.micSensitivity, + midiToFrequency(instrumentMinMidi - 1) + ); + + // Game Logic Hook (matching uses the raw detection; display is gated below) const { targetMidi, feedbackMessage, @@ -47,6 +69,13 @@ function App() { handleVirtualInstrumentPlay } = useGameLogic(pitchData); + // Live feedback shows only what the USER plays. While the app itself makes + // sound (ear-training reference note, Play Note, virtual instruments), the + // mic picks up the speaker — displaying that would leak the target note in + // ear training. The moment playback ends (plus decay tail), the note the + // user is playing appears again. Re-evaluated on every detection frame. + const displayedPitch = audioEngine.isAudible() ? null : pitchData; + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false); const [hoveredMidi, setHoveredMidi] = useState(null); @@ -63,8 +92,12 @@ function App() { return () => window.removeEventListener('resize', handleResize); }, []); - // Theme support - useEffect(() => { + // Theme support — MUST be a layout effect: children (e.g. SheetMusic) read the + // resolved theme colors from getComputedStyle inside their own passive + // effects. Layout effects run before any passive effects, so the + // data-theme attribute is applied by the time they read it — otherwise the + // rendered notation lags one theme switch behind. + useLayoutEffect(() => { const root = document.documentElement; const theme = settings.theme || 'auto'; if (theme === 'auto') { @@ -94,9 +127,6 @@ function App() { } }, []); - 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); @@ -128,7 +158,7 @@ function App() { break; case 'r': // Replay case 'p': // Play - playNote(targetMidi, 1.0); + playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5); break; case 'm': // Toggle Game Mode setSettings(s => ({ @@ -157,7 +187,9 @@ function App() { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [settings.zenMode, showHelp, generateNewNote, playNote, targetMidi]); + // `settings` (not individual fields): the handler reads several settings + // (gameMode, referenceNoteDuration, ...) and re-attaching a listener is cheap. + }, [settings, showHelp, generateNewNote, playNote, targetMidi, setSettings]); // Hint text construction const hintPositions = useMemo(() => { @@ -181,10 +213,12 @@ function App() { return getFretboardPositions(targetMidi, currentTuning); }, [settings.showHint, settings.showFretboard, targetMidi, currentTuning, currentInstrumentDef]); - // Auto-enable mic when tuner is turned on + // Auto-enable mic when tuner is turned on (rAF defers the state update + // out of the effect phase to avoid cascading renders) useEffect(() => { if (settings.showTuningMeter && !listening) { - setListening(true); + const id = requestAnimationFrame(() => setListening(true)); + return () => cancelAnimationFrame(id); } }, [settings.showTuningMeter, listening]); @@ -250,7 +284,9 @@ function App() {
@@ -354,7 +391,7 @@ function App() { -
- {pitchData ? ( +
+ {displayedPitch ? ( <> - - {pitchData.note} + + {displayedPitch.note} - {Math.round(pitchData.frequency)} Hz + {Math.round(displayedPitch.frequency)} Hz ) : ( {listening ? "Listening..." : "Mic Off"} @@ -402,7 +439,7 @@ function App() { {!settings.zenMode && (
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 9c52e21..25cd710 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ 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, Clock } from 'lucide-react'; import type { AppSettings } from '../types/SettingsTypes'; import { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig'; import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector'; @@ -19,6 +19,9 @@ interface SettingsModalProps { const LevelMeter: React.FC<{ level: number; isActive: boolean }> = ({ level, isActive }) => { // Convert RMS to dB for display, then normalize to 0-100% // RMS of 0.001 = -60dB, RMS of 1.0 = 0dB + // `level` is already EMA-smoothed in the detection loop — no CSS transition + // here (a transition on a value that changes every frame gets interrupted + // 60x/sec, which Safari/WebKit handles by leaving the bar stuck). const db = level > 0 ? 20 * Math.log10(level) : -120; // Map full range from -100dB to 0dB => 0% to 100% @@ -47,8 +50,7 @@ const LevelMeter: React.FC<{ level: number; isActive: boolean }> = ({ level, isA height: '100%', width: `${percentage}%`, background: percentage > 80 ? '#ef4444' : percentage > 50 ? '#f59e0b' : '#22c55e', - borderRadius: '6px', - transition: 'width 0.05s ease-out' + borderRadius: '6px' }} />
@@ -91,7 +93,7 @@ export const SettingsModal: React.FC = ({ ].map(mode => ( +
+

+ Holds the next reference note until your instrument has gone quiet, + so a still-ringing string can't mask it. (Requires the microphone.) +

+
+
{/* Microphone Toggle */}