diff --git a/src/App.tsx b/src/App.tsx index d6b7f73..03ec827 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { } from './music/Tunings'; import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs'; import { FretboardHint } from './components/FretboardHint'; +import { TuningMeter } from './components/TuningMeter'; import { Mic, MicOff, SkipForward, HelpCircle } from 'lucide-react'; import './App.css'; import './styles/skip-button.css'; @@ -27,6 +28,7 @@ function App() { const [settings, setSettings] = useState({ difficulty: 'first_pos', showHint: false, + showTuningMeter: false, tuningId: 'standard', keySignature: 'C', instrument: 'guitar', @@ -233,18 +235,22 @@ function App() { {listening ? : } -
- {pitchData ? ( - <> - - {pitchData.note} - - {Math.round(pitchData.frequency)} Hz - - ) : ( - {listening ? "Listening..." : "Mic Off"} - )} -
+ {settings.showTuningMeter && pitchData ? ( + + ) : ( +
+ {pitchData ? ( + <> + + {pitchData.note} + + {Math.round(pitchData.frequency)} Hz + + ) : ( + {listening ? "Listening..." : "Mic Off"} + )} +
+ )} diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index e030abe..8f1bbd2 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -1,4 +1,4 @@ -import { Settings, Guitar, Music } from 'lucide-react'; +import { Settings, Guitar, Music, Gauge } from 'lucide-react'; import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; @@ -20,6 +20,7 @@ export interface AppSettings { tuningId: string; keySignature: string; instrument: string; + showTuningMeter: boolean; rhythm: RhythmSettings; } @@ -227,7 +228,19 @@ export const Controls: React.FC = ({ settings, onUpdateSettings } )} - +
+ + +
diff --git a/src/components/TuningMeter.css b/src/components/TuningMeter.css new file mode 100644 index 0000000..5370b70 --- /dev/null +++ b/src/components/TuningMeter.css @@ -0,0 +1,118 @@ +.tuning-meter-container { + display: flex; + flex-direction: column; + align-items: center; + width: 260px; + background: rgba(0, 0, 0, 0.4); + padding: 12px; + border-radius: 12px; + backdrop-filter: blur(4px); + border: 1px solid rgba(255, 255, 255, 0.1); + margin-top: 10px; +} + +.meter-scale { + position: relative; + width: 100%; + height: 24px; + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + margin-bottom: 8px; + overflow: hidden; +} + +.tick { + position: absolute; + top: 4px; + font-size: 10px; + color: rgba(255, 255, 255, 0.5); + transform: translateX(-50%); +} + +.tick-left { + left: 10%; +} + +.tick-center { + left: 50%; +} + +.tick-right { + left: 90%; +} + +.center-line { + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 2px; + background: rgba(255, 255, 255, 0.3); + transform: translateX(-50%); + z-index: 1; +} + +.meter-needle { + position: absolute; + top: 0; + bottom: 0; + width: 4px; + background: white; + transform: translateX(-50%); + transition: left 0.1s ease-out; + z-index: 2; + border-radius: 2px; + box-shadow: 0 0 8px rgba(255, 255, 255, 0.5); +} + +.meter-needle.perfect { + background-color: #4ade80; + box-shadow: 0 0 10px #4ade80; +} + +/* Tailwind Green-400 */ +.meter-needle.good { + background-color: #86efac; +} + +/* Tailwind Green-300 */ +.meter-needle.okay { + background-color: #facc15; +} + +/* Tailwind Yellow-400 */ +.meter-needle.poor { + background-color: #f87171; +} + +/* Tailwind Red-400 */ + +.tuning-readout { + display: flex; + align-items: baseline; + gap: 8px; + font-family: monospace; +} + +.tuning-readout .note-name { + font-size: 1.25rem; + font-weight: bold; + color: white; +} + +.tuning-readout .cents-value { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.7); +} + +.tuning-readout .cents-value.perfect { + color: #4ade80; +} + +.tuning-readout .cents-value.okay { + color: #facc15; +} + +.tuning-readout .cents-value.poor { + color: #f87171; +} \ No newline at end of file diff --git a/src/components/TuningMeter.tsx b/src/components/TuningMeter.tsx new file mode 100644 index 0000000..ae531d1 --- /dev/null +++ b/src/components/TuningMeter.tsx @@ -0,0 +1,58 @@ + +import React from 'react'; +import './TuningMeter.css'; + +interface TuningMeterProps { + cents: number; // Deviation in cents from nearest note (-50 to +50 usually) + noteName: string; // The detected note name (e.g. "A4") +} + +export const TuningMeter: React.FC = ({ cents, noteName }) => { + // Clamp cents for display (-50 to 50) + const clampedCents = Math.max(-50, Math.min(50, cents)); + + // Map -50..50 to 0..100% for position + // -50 cents -> 0% + // 0 cents -> 50% + // 50 cents -> 100% + const positionPercent = ((clampedCents + 50) / 100) * 100; + + // Color determination + let statusClass = 'neutral'; + if (Math.abs(cents) < 5) { + statusClass = 'perfect'; // Green + } else if (Math.abs(cents) < 15) { + statusClass = 'good'; // Green-ish + } else if (Math.abs(cents) < 30) { + statusClass = 'okay'; // Yellow + } else { + statusClass = 'poor'; // Red + } + + return ( +
+
+
-50
+
0
+
+50
+ + {/* Center Marker Line */} +
+ + {/* The Needle/Indicator */} +
+
+
+
+
+ {noteName} + + {cents > 0 ? '+' : ''}{Math.round(cents)} ct + +
+
+ ); +}; diff --git a/src/hooks/usePitchDetector.ts b/src/hooks/usePitchDetector.ts index de41cc7..9a14379 100644 --- a/src/hooks/usePitchDetector.ts +++ b/src/hooks/usePitchDetector.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { PitchAnalyzer } from '../audio/PitchAnalyzer'; -import { frequencyToMidi, getNoteDetails } from '../music/NoteUtils'; +import { frequencyToMidi, getNoteDetails, getCentDifference } from '../music/NoteUtils'; interface PitchData { frequency: number; @@ -28,11 +28,13 @@ export function usePitchDetector(active: boolean) { // Calculate cents off purely for display if needed, // but for training we usually care if midi matches. + const cents = getCentDifference(freq, midi); + setPitchData({ frequency: freq, midi, note: scientific, - cents: 0, + cents, clarity: 1 }); }