mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-09-02 09:34:33 +02:00
UI fixed
This commit is contained in:
55
src/App.css
55
src/App.css
@@ -241,3 +241,58 @@
|
||||
.animate-pop {
|
||||
animation: pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||
}
|
||||
|
||||
/* Switch Button for Metronome */
|
||||
.switch-button {
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 99px;
|
||||
background-color: #ddd;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
padding: 0;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch-button.active {
|
||||
background-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.switch-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background-color: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
transition: transform 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.switch-button.active .switch-thumb {
|
||||
transform: translateX(17px);
|
||||
}
|
||||
|
||||
/* Hint Button styling - similar to skip button but distinct */
|
||||
.hint-button {
|
||||
background-color: transparent;
|
||||
border: 2px solid var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 30px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.hint-button:hover {
|
||||
background-color: rgba(var(--hue-primary), 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
84
src/App.tsx
84
src/App.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { SheetMusic } from './components/SheetMusic';
|
||||
import { Controls, type AppSettings } from './components/Controls';
|
||||
import { usePitchDetector } from './hooks/usePitchDetector';
|
||||
import { useMetronome } from './hooks/useMetronome';
|
||||
import {
|
||||
getRandomNote,
|
||||
getNoteDetails
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
getFretboardPositions
|
||||
} from './music/Tunings';
|
||||
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
|
||||
import { Mic, MicOff, SkipForward } from 'lucide-react';
|
||||
import { Mic, MicOff, SkipForward, HelpCircle } from 'lucide-react';
|
||||
import './App.css';
|
||||
import './styles/skip-button.css';
|
||||
|
||||
@@ -23,14 +24,22 @@ function App() {
|
||||
|
||||
const [targetMidi, setTargetMidi] = useState<number>(60); // Start with C4
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
difficulty: 'first_pos', // Will be dynamic, but initial default needed
|
||||
difficulty: 'first_pos',
|
||||
showHint: false,
|
||||
tuningId: 'standard',
|
||||
keySignature: 'C',
|
||||
instrument: 'guitar'
|
||||
instrument: 'guitar',
|
||||
rhythm: {
|
||||
mode: 'bpm',
|
||||
bpm: 60,
|
||||
seconds: 5,
|
||||
active: false,
|
||||
autoAdvance: false,
|
||||
sound: true,
|
||||
volume: 0.5
|
||||
}
|
||||
});
|
||||
|
||||
const [streak, setStreak] = useState(0);
|
||||
const [matchStartTime, setMatchStartTime] = useState<number | null>(null);
|
||||
const [feedbackMessage, setFeedbackMessage] = useState<string>("");
|
||||
|
||||
@@ -78,6 +87,30 @@ function App() {
|
||||
}, [settings.difficulty, settings.tuningId]);
|
||||
|
||||
|
||||
// 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
|
||||
});
|
||||
|
||||
|
||||
// Match Logic
|
||||
useEffect(() => {
|
||||
if (!pitchData) {
|
||||
@@ -91,23 +124,42 @@ function App() {
|
||||
} else {
|
||||
const duration = Date.now() - matchStartTime;
|
||||
if (duration > NOTE_MATCH_THRESHOLD_MS) {
|
||||
setStreak(s => s + 1);
|
||||
// Success!
|
||||
setFeedbackMessage("Good!");
|
||||
|
||||
// Simple flash effect or delay
|
||||
const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance;
|
||||
const isTimerMode = settings.rhythm.mode === 'seconds';
|
||||
|
||||
if (!isRhythmActive) {
|
||||
// Standard or Rhythm-Manual
|
||||
|
||||
setTimeout(() => {
|
||||
generateNewNote();
|
||||
}, 800);
|
||||
|
||||
setMatchStartTime(null);
|
||||
} 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 {
|
||||
setMatchStartTime(null);
|
||||
}
|
||||
}, [pitchData, targetMidi, matchStartTime, generateNewNote]);
|
||||
|
||||
|
||||
}, [pitchData, targetMidi, matchStartTime, generateNewNote, settings.rhythm, restartMetronome, feedbackMessage]);
|
||||
|
||||
// Hint text construction
|
||||
const hintPositions = useMemo(() => {
|
||||
@@ -120,8 +172,7 @@ function App() {
|
||||
return (
|
||||
<div className="app-container">
|
||||
<header className="app-header">
|
||||
<div className="logo">Antigravity Guitar</div>
|
||||
<div className="streak-badge">Streak: <strong>{streak}</strong></div>
|
||||
<div className="logo">Sheet music trainer</div>
|
||||
</header>
|
||||
|
||||
<main className="main-stage">
|
||||
@@ -161,7 +212,14 @@ function App() {
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center' }}>
|
||||
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
|
||||
<button
|
||||
className={`hint-button ${settings.showHint ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
{settings.showHint ? "Hide Hint" : "Show Hint"}
|
||||
</button>
|
||||
<button className="skip-button" onClick={generateNewNote}>
|
||||
<SkipForward size={18} />
|
||||
Skip Note
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Settings, HelpCircle, Guitar, Music } from 'lucide-react';
|
||||
import { Settings, Guitar, Music } from 'lucide-react';
|
||||
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
|
||||
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
||||
|
||||
export type Difficulty = 'all' | 'first_pos' | 'open' | 'e_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;
|
||||
tuningId: string;
|
||||
keySignature: string;
|
||||
instrument: string;
|
||||
rhythm: RhythmSettings;
|
||||
}
|
||||
|
||||
interface ControlsProps {
|
||||
@@ -34,7 +44,6 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
|
||||
const defaultRange = instDef.ranges[0].id;
|
||||
|
||||
// Reset tuning if applicable, or just keep as is (it won't be shown/used)
|
||||
// If instrument has tunings, pick first.
|
||||
let newTuningId = settings.tuningId;
|
||||
if (instDef.showTuning && INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass']) {
|
||||
newTuningId = INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass'][0];
|
||||
@@ -43,18 +52,23 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
instrument: newInstrumentId,
|
||||
difficulty: defaultRange as Difficulty, // flexible casting
|
||||
difficulty: defaultRange as Difficulty,
|
||||
tuningId: newTuningId
|
||||
});
|
||||
};
|
||||
|
||||
const updateRhythm = (updates: Partial<RhythmSettings>) => {
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
rhythm: { ...settings.rhythm, ...updates }
|
||||
});
|
||||
};
|
||||
|
||||
// Cast instrument to specific key if needed, or use generic record access
|
||||
const availableTunings = INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] || [];
|
||||
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||
|
||||
const toggleHint = () => {
|
||||
onUpdateSettings({ ...settings, showHint: !settings.showHint });
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="controls-container">
|
||||
@@ -108,10 +122,9 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="control-group">
|
||||
<label className="control-label">
|
||||
<span>Key Signature</span>
|
||||
<span>Key</span>
|
||||
</label>
|
||||
<select
|
||||
value={settings.keySignature}
|
||||
@@ -136,14 +149,88 @@ export const Controls: React.FC<ControlsProps> = ({ settings, onUpdateSettings }
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Rhythm Controls */}
|
||||
<div className="control-group rhythm-group" style={{ borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '12px', marginTop: '12px', width: '100%' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '12px', marginBottom: '8px' }}>
|
||||
<label className="control-label" style={{ marginBottom: 0 }}>
|
||||
<span>Metronome / Timer</span>
|
||||
</label>
|
||||
<button
|
||||
className={`control-button ${settings.showHint ? 'active' : ''}`}
|
||||
onClick={toggleHint}
|
||||
title="Show Hint"
|
||||
className={`switch-button ${settings.rhythm.active ? 'active' : ''}`}
|
||||
onClick={() => updateRhythm({ active: !settings.rhythm.active })}
|
||||
title={settings.rhythm.active ? "Turn Off" : "Turn On"}
|
||||
>
|
||||
<HelpCircle size={20} />
|
||||
<div className="switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings.rhythm.active && (
|
||||
<div className="rhythm-details" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
className={`control-button small ${settings.rhythm.mode === 'bpm' ? 'active' : ''}`}
|
||||
onClick={() => updateRhythm({ mode: 'bpm' })}
|
||||
>BPM</button>
|
||||
<button
|
||||
className={`control-button small ${settings.rhythm.mode === 'seconds' ? 'active' : ''}`}
|
||||
onClick={() => updateRhythm({ mode: 'seconds' })}
|
||||
>Timer</button>
|
||||
</div>
|
||||
|
||||
{settings.rhythm.mode === 'bpm' ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '12px', minWidth: '40px' }}>{settings.rhythm.bpm} BPM</span>
|
||||
<input
|
||||
type="range"
|
||||
min="30"
|
||||
max="240"
|
||||
step="5"
|
||||
value={settings.rhythm.bpm}
|
||||
onChange={(e) => updateRhythm({ bpm: Number(e.target.value) })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '12px', minWidth: '40px' }}>{settings.rhythm.seconds}s</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="60"
|
||||
step="1"
|
||||
value={settings.rhythm.seconds}
|
||||
onChange={(e) => updateRhythm({ seconds: Number(e.target.value) })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.rhythm.autoAdvance}
|
||||
onChange={(e) => updateRhythm({ autoAdvance: e.target.checked })}
|
||||
/>
|
||||
Auto-Adv
|
||||
</label>
|
||||
|
||||
<label style={{ fontSize: '12px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.rhythm.sound}
|
||||
onChange={(e) => updateRhythm({ sound: e.target.checked })}
|
||||
/>
|
||||
Sound
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
102
src/hooks/useMetronome.ts
Normal file
102
src/hooks/useMetronome.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface MetronomeOptions {
|
||||
bpm: number;
|
||||
volume: number; // 0.0 to 1.0
|
||||
playing: boolean;
|
||||
onTick: (beat: number) => void;
|
||||
}
|
||||
|
||||
export interface MetronomeHandle {
|
||||
restart: () => void;
|
||||
}
|
||||
|
||||
export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions): MetronomeHandle {
|
||||
const audioContext = useRef<AudioContext | null>(null);
|
||||
const nextNoteTime = useRef<number>(0.0);
|
||||
const timerID = useRef<number | null>(null);
|
||||
const lookahead = 25.0; // How frequently to call scheduling function (in milliseconds)
|
||||
const scheduleAheadTime = 0.1; // How far ahead to schedule audio (sec)
|
||||
const currentBeat = useRef<number>(0);
|
||||
|
||||
// Keep callback fresh without re-triggering effect
|
||||
const onTickRef = useRef(onTick);
|
||||
useEffect(() => {
|
||||
onTickRef.current = onTick;
|
||||
}, [onTick]);
|
||||
|
||||
const nextNote = useCallback(() => {
|
||||
const secondsPerBeat = 60.0 / bpm;
|
||||
nextNoteTime.current += secondsPerBeat;
|
||||
currentBeat.current++;
|
||||
}, [bpm]);
|
||||
|
||||
const playClick = useCallback((time: number) => {
|
||||
if (!audioContext.current) return;
|
||||
|
||||
const osc = audioContext.current.createOscillator();
|
||||
const gainNode = audioContext.current.createGain();
|
||||
|
||||
osc.connect(gainNode);
|
||||
gainNode.connect(audioContext.current.destination);
|
||||
|
||||
// Click sound: high pitch, very short decay
|
||||
osc.frequency.value = 880;
|
||||
|
||||
// Simple envelope
|
||||
gainNode.gain.setValueAtTime(volume, time);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.05);
|
||||
|
||||
osc.start(time);
|
||||
osc.stop(time + 0.05);
|
||||
|
||||
onTickRef.current(currentBeat.current);
|
||||
}, [volume]);
|
||||
|
||||
const scheduler = useCallback(() => {
|
||||
if (!audioContext.current) return;
|
||||
|
||||
// while there are notes that will need to play before the next interval,
|
||||
// schedule them and advance the pointer.
|
||||
while (nextNoteTime.current < audioContext.current.currentTime + scheduleAheadTime) {
|
||||
playClick(nextNoteTime.current);
|
||||
nextNote();
|
||||
}
|
||||
timerID.current = window.setTimeout(scheduler, lookahead);
|
||||
}, [nextNote, playClick]);
|
||||
|
||||
const restart = useCallback(() => {
|
||||
if (audioContext.current) {
|
||||
// Reset to play one beat interval from NOW
|
||||
const secondsPerBeat = 60.0 / bpm;
|
||||
nextNoteTime.current = audioContext.current.currentTime + secondsPerBeat;
|
||||
// Should we reset currentBeat?
|
||||
currentBeat.current = 0;
|
||||
}
|
||||
}, [bpm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (playing) {
|
||||
if (!audioContext.current) {
|
||||
audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
}
|
||||
|
||||
// Resume if suspended (browser autoplay policy)
|
||||
if (audioContext.current.state === 'suspended') {
|
||||
audioContext.current.resume();
|
||||
}
|
||||
|
||||
currentBeat.current = 0;
|
||||
nextNoteTime.current = audioContext.current.currentTime + 0.05;
|
||||
scheduler();
|
||||
|
||||
return () => {
|
||||
if (timerID.current) window.clearTimeout(timerID.current);
|
||||
};
|
||||
} else {
|
||||
if (timerID.current) window.clearTimeout(timerID.current);
|
||||
}
|
||||
}, [playing, scheduler]);
|
||||
|
||||
return { restart };
|
||||
}
|
||||
Reference in New Issue
Block a user