mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Phrase mode: App integration (mode toggle, keyboard, zen), Controls tweaks, styles
This commit is contained in:
117
src/App.css
117
src/App.css
@@ -981,3 +981,120 @@ button {
|
|||||||
.fretboard-container {
|
.fretboard-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
/* ------------------------- Phrase Mode ------------------------- */
|
||||||
|
|
||||||
|
.phrase-card {
|
||||||
|
max-width: 920px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-range {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-status {
|
||||||
|
min-height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-transport {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-error {
|
||||||
|
padding: 24px 8px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-warning {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-warning);
|
||||||
|
text-align: center;
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-note {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
margin: 6px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-setup {
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-setup summary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-setup summary:hover {
|
||||||
|
color: var(--color-text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-setup-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: 10px 14px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-setup-grid label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-setup-grid select,
|
||||||
|
.phrase-setup-grid input[type='number'] {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-text-main) 20%, transparent);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-text-main);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-check {
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--color-text-main) !important;
|
||||||
|
align-self: end;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-sheet {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|||||||
387
src/App.tsx
387
src/App.tsx
@@ -1,41 +1,30 @@
|
|||||||
import { useState, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } 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 { Controls } 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 { SingleNoteTrainer, type SingleNoteHandle } from './components/SingleNoteTrainer';
|
||||||
|
import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer';
|
||||||
import { usePitchDetector } from './hooks/usePitchDetector';
|
import { usePitchDetector } from './hooks/usePitchDetector';
|
||||||
import { useAudioPlayer } from './hooks/useAudioPlayer';
|
|
||||||
import { useSettings } from './context/useSettings';
|
import { useSettings } from './context/useSettings';
|
||||||
import { useGameLogic } from './hooks/useGameLogic';
|
|
||||||
import { audioEngine } from './audio/AudioEngine';
|
import { audioEngine } from './audio/AudioEngine';
|
||||||
import { getNoteDetails, midiToFrequency } from './music/NoteUtils';
|
import { midiToFrequency } from './music/NoteUtils';
|
||||||
import {
|
import { TUNINGS } from './music/Tunings';
|
||||||
TUNINGS,
|
|
||||||
getFretboardPositions,
|
|
||||||
} from './music/Tunings';
|
|
||||||
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
|
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 { Mic, MicOff, HelpCircle, X, Settings, Maximize, Minimize } from 'lucide-react';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import './styles/skip-button.css';
|
import './styles/skip-button.css';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [listening, setListening] = useState(false);
|
const [listening, setListening] = useState(false);
|
||||||
const { playNote } = useAudioPlayer();
|
|
||||||
|
|
||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
// Alias to keep existing code working with minimal changes
|
|
||||||
const setSettings = updateSettings;
|
const setSettings = updateSettings;
|
||||||
|
|
||||||
|
const isPhrase = settings.gameMode === 'phrase';
|
||||||
|
|
||||||
const currentTuning = TUNINGS[settings.tuningId];
|
const currentTuning = TUNINGS[settings.tuningId];
|
||||||
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
|
||||||
|
|
||||||
@@ -59,26 +48,15 @@ function App() {
|
|||||||
midiToFrequency(instrumentMinMidi - 1)
|
midiToFrequency(instrumentMinMidi - 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Game Logic Hook (matching uses the raw detection; display is gated below)
|
// Live feedback shows only what the USER plays, gated while the app's own
|
||||||
const {
|
// speaker output is audible (see SingleNoteTrainer / PhraseTrainer).
|
||||||
targetMidi,
|
|
||||||
feedbackMessage,
|
|
||||||
revealed,
|
|
||||||
virtualNote,
|
|
||||||
generateNewNote,
|
|
||||||
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 displayedPitch = audioEngine.isAudible() ? null : pitchData;
|
||||||
|
|
||||||
|
const singleNoteRef = useRef<SingleNoteHandle>(null);
|
||||||
|
const phraseRef = useRef<PhraseHandle>(null);
|
||||||
|
|
||||||
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 [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);
|
||||||
@@ -92,11 +70,8 @@ function App() {
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Theme support — MUST be a layout effect: children (e.g. SheetMusic) read the
|
// Theme support — MUST be a layout effect: children read the resolved theme
|
||||||
// resolved theme colors from getComputedStyle inside their own passive
|
// colors from getComputedStyle inside their own passive effects.
|
||||||
// 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(() => {
|
useLayoutEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const theme = settings.theme || 'auto';
|
const theme = settings.theme || 'auto';
|
||||||
@@ -127,28 +102,77 @@ function App() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Resolve overrides
|
|
||||||
const currentRangeDef = useMemo(() => {
|
|
||||||
return currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
|
|
||||||
}, [currentInstrumentDef, settings.difficulty]);
|
|
||||||
|
|
||||||
// Determine active clef/transpose for rendering
|
|
||||||
const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode;
|
|
||||||
const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose;
|
|
||||||
|
|
||||||
/* Keyboard Shortcuts */
|
/* Keyboard Shortcuts */
|
||||||
const [showHelp, setShowHelp] = useState(false);
|
const [showHelp, setShowHelp] = useState(false);
|
||||||
|
|
||||||
|
// Opening dialogs pauses a running phrase run (contract E1/lifecycle).
|
||||||
|
const openSettings = useCallback(() => {
|
||||||
|
phraseRef.current?.pause();
|
||||||
|
setIsSettingsOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openHelp = useCallback(() => {
|
||||||
|
phraseRef.current?.pause();
|
||||||
|
setShowHelp(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
// Ignore if typing in an input
|
const target = e.target as HTMLElement | null;
|
||||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
|
const onInteractive =
|
||||||
|
target instanceof HTMLInputElement ||
|
||||||
|
target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLButtonElement ||
|
||||||
|
target instanceof HTMLSelectElement ||
|
||||||
|
!!target?.isContentEditable;
|
||||||
|
|
||||||
// prevent default for space to stop scrolling
|
if (e.code === 'Escape') {
|
||||||
|
if (showHelp) setShowHelp(false);
|
||||||
|
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Focused buttons handle their own keys (native activation);
|
||||||
|
// global shortcuts must not double-fire or block them.
|
||||||
|
if (onInteractive) return;
|
||||||
|
|
||||||
|
// Prevent default for space to stop scrolling
|
||||||
if (e.code === 'Space') {
|
if (e.code === 'Space') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isPhrase) {
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case 'h':
|
||||||
|
setSettings(s => ({ ...s, showHint: !s.showHint }));
|
||||||
|
break;
|
||||||
|
case 'z':
|
||||||
|
setSettings(s => ({ ...s, zenMode: !s.zenMode }));
|
||||||
|
break;
|
||||||
|
case 'r':
|
||||||
|
phraseRef.current?.retry();
|
||||||
|
break;
|
||||||
|
case 'p':
|
||||||
|
phraseRef.current?.previewToggle();
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
phraseRef.current?.next();
|
||||||
|
break;
|
||||||
|
case 's':
|
||||||
|
phraseRef.current?.skip();
|
||||||
|
break;
|
||||||
|
case 'v':
|
||||||
|
setSettings(s => ({ ...s, showFretboard: !s.showFretboard }));
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
setListening(l => !l);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (e.code === 'Space' || e.code === 'Enter') {
|
||||||
|
phraseRef.current?.pauseToggle();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (e.key.toLowerCase()) {
|
switch (e.key.toLowerCase()) {
|
||||||
case 'h':
|
case 'h':
|
||||||
setSettings(s => ({ ...s, showHint: !s.showHint }));
|
setSettings(s => ({ ...s, showHint: !s.showHint }));
|
||||||
@@ -158,9 +182,9 @@ function App() {
|
|||||||
break;
|
break;
|
||||||
case 'r': // Replay
|
case 'r': // Replay
|
||||||
case 'p': // Play
|
case 'p': // Play
|
||||||
playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5);
|
singleNoteRef.current?.playCurrent();
|
||||||
break;
|
break;
|
||||||
case 'm': // Toggle Game Mode
|
case 'm': // Cycle game mode
|
||||||
setSettings(s => ({
|
setSettings(s => ({
|
||||||
...s,
|
...s,
|
||||||
gameMode: s.gameMode === 'sight_reading' ? 'ear_training' : 'sight_reading'
|
gameMode: s.gameMode === 'sight_reading' ? 'ear_training' : 'sight_reading'
|
||||||
@@ -174,46 +198,31 @@ function App() {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check non-character keys via code to avoid layout issues for function keys
|
|
||||||
if (e.code === 'Space' || e.code === 'Enter') {
|
if (e.code === 'Space' || e.code === 'Enter') {
|
||||||
generateNewNote();
|
singleNoteRef.current?.skipNote();
|
||||||
}
|
|
||||||
|
|
||||||
if (e.code === 'Escape') {
|
|
||||||
if (showHelp) setShowHelp(false);
|
|
||||||
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
// `settings` (not individual fields): the handler reads several settings
|
}, [settings, showHelp, isPhrase, setSettings]);
|
||||||
// (gameMode, referenceNoteDuration, ...) and re-attaching a listener is cheap.
|
|
||||||
}, [settings, showHelp, generateNewNote, playNote, targetMidi, setSettings]);
|
|
||||||
|
|
||||||
// Hint text construction
|
// 'm' cycles all three modes from phrase mode too.
|
||||||
const hintPositions = useMemo(() => {
|
useEffect(() => {
|
||||||
// If showing full hint OR detecting via fretboard, we need positions?
|
const handler = (e: KeyboardEvent) => {
|
||||||
// Actually, showHint=true renders the red dots.
|
if (e.key.toLowerCase() !== 'm') return;
|
||||||
// If showFretboard=true but showHint=false, we render empty board (interactive).
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ||
|
||||||
|
target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || target?.isContentEditable) return;
|
||||||
|
if (settings.gameMode === 'phrase') {
|
||||||
|
setSettings(s => ({ ...s, gameMode: 'sight_reading' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler);
|
||||||
|
return () => window.removeEventListener('keydown', handler);
|
||||||
|
}, [settings.gameMode, setSettings]);
|
||||||
|
|
||||||
if (!settings.showHint && !settings.showFretboard) return [];
|
// Auto-enable mic when the tuner is turned on (rAF defers the state update
|
||||||
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 (rAF defers the state update
|
|
||||||
// out of the effect phase to avoid cascading renders)
|
// out of the effect phase to avoid cascading renders)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.showTuningMeter && !listening) {
|
if (settings.showTuningMeter && !listening) {
|
||||||
@@ -245,6 +254,13 @@ function App() {
|
|||||||
>
|
>
|
||||||
Ear Training
|
Ear Training
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className={`toggle-option ${settings.gameMode === 'phrase' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSettings(s => ({ ...s, gameMode: 'phrase' }))}
|
||||||
|
title="Play short phrases and melodies"
|
||||||
|
>
|
||||||
|
Phrases
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -263,14 +279,14 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="icon-button help-btn"
|
className="icon-button help-btn"
|
||||||
onClick={() => setShowHelp(true)}
|
onClick={openHelp}
|
||||||
title="Shortcuts Help"
|
title="Shortcuts Help"
|
||||||
>
|
>
|
||||||
<HelpCircle size={24} />
|
<HelpCircle size={24} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="icon-button"
|
className="icon-button"
|
||||||
onClick={() => setIsSettingsOpen(true)}
|
onClick={openSettings}
|
||||||
title="Settings"
|
title="Settings"
|
||||||
>
|
>
|
||||||
<Settings size={24} />
|
<Settings size={24} />
|
||||||
@@ -280,132 +296,23 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<main className="main-stage">
|
<main className="main-stage">
|
||||||
<div className={`card sheet-music-card ${(settings.showHint || settings.showFretboard) ? 'has-hint' : ''} ${settings.zenMode ? 'zen-mode' : ''}`}>
|
{isPhrase ? (
|
||||||
<div className="sheet-music-container">
|
<PhraseTrainer
|
||||||
<SheetMusic
|
ref={phraseRef}
|
||||||
targetMidi={targetMidi}
|
listening={listening}
|
||||||
// Virtual fretboard taps are explicit user input and always
|
micError={error}
|
||||||
// show; the mic preview is gated during speaker playback.
|
windowWidth={windowWidth}
|
||||||
playedMidi={virtualNote ?? displayedPitch?.midi}
|
/>
|
||||||
keySignature={settings.keySignature}
|
) : (
|
||||||
clef={activeClef}
|
<SingleNoteTrainer
|
||||||
transpose={activeTranspose}
|
ref={singleNoteRef}
|
||||||
width={Math.min(windowWidth - 40, 500)}
|
pitchData={pitchData}
|
||||||
height={
|
windowWidth={windowWidth}
|
||||||
activeClef === 'grand'
|
windowHeight={windowHeight}
|
||||||
? (windowHeight < 500 ? 190 : 260) // Compact Grand Staff if short screen
|
/>
|
||||||
: (windowHeight < 500 ? 120 : 180) // Compact Single Staff
|
)}
|
||||||
}
|
|
||||||
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
|
|
||||||
hoverMidi={settings.showHint ? hoveredMidi : null}
|
|
||||||
theme={settings.theme}
|
|
||||||
/>
|
|
||||||
</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, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
|
|
||||||
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>
|
|
||||||
|
|
||||||
|
{error && <div className="error-message">{error}</div>}
|
||||||
|
|
||||||
{
|
{
|
||||||
!settings.zenMode && (
|
!settings.zenMode && (
|
||||||
@@ -422,7 +329,7 @@ function App() {
|
|||||||
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
|
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
|
||||||
{displayedPitch ? (
|
{displayedPitch ? (
|
||||||
<>
|
<>
|
||||||
<span className={`detected-note ${displayedPitch.midi === targetMidi ? 'match' : ''}`}>
|
<span className="detected-note">
|
||||||
{displayedPitch.note}
|
{displayedPitch.note}
|
||||||
</span>
|
</span>
|
||||||
<span className="detected-hz">{Math.round(displayedPitch.frequency)} Hz</span>
|
<span className="detected-hz">{Math.round(displayedPitch.frequency)} Hz</span>
|
||||||
@@ -471,38 +378,26 @@ function App() {
|
|||||||
<button className="help-close" onClick={() => setShowHelp(false)}><X size={20} /></button>
|
<button className="help-close" onClick={() => setShowHelp(false)}><X size={20} /></button>
|
||||||
<h2 style={{ marginTop: 0, marginBottom: '24px' }}>Keyboard Shortcuts</h2>
|
<h2 style={{ marginTop: 0, marginBottom: '24px' }}>Keyboard Shortcuts</h2>
|
||||||
|
|
||||||
<div className="help-item">
|
{isPhrase ? (
|
||||||
<span>Skip Note</span>
|
<>
|
||||||
<span className="shortcut-key">Space</span>
|
<div className="help-item"><span>Start / Pause / Resume</span><span className="shortcut-key">Space</span></div>
|
||||||
</div>
|
<div className="help-item"><span>Preview phrase (Play / Stop)</span><span className="shortcut-key">P</span></div>
|
||||||
<div className="help-item">
|
<div className="help-item"><span>Retry phrase</span><span className="shortcut-key">R</span></div>
|
||||||
<span>Toggle Hint</span>
|
<div className="help-item"><span>New melody / next bars</span><span className="shortcut-key">N</span></div>
|
||||||
<span className="shortcut-key">H</span>
|
<div className="help-item"><span>Skip note (at your pace)</span><span className="shortcut-key">S</span></div>
|
||||||
</div>
|
</>
|
||||||
<div className="help-item">
|
) : (
|
||||||
<span>Toggle Virtual Instrument</span>
|
<>
|
||||||
<span className="shortcut-key">V</span>
|
<div className="help-item"><span>Skip Note</span><span className="shortcut-key">Space</span></div>
|
||||||
</div>
|
<div className="help-item"><span>Replay Note</span><span className="shortcut-key">R</span></div>
|
||||||
<div className="help-item">
|
<div className="help-item"><span>Toggle Game Mode</span><span className="shortcut-key">M</span></div>
|
||||||
<span>Toggle Mic</span>
|
</>
|
||||||
<span className="shortcut-key">L</span>
|
)}
|
||||||
</div>
|
<div className="help-item"><span>Toggle Hint</span><span className="shortcut-key">H</span></div>
|
||||||
<div className="help-item">
|
<div className="help-item"><span>Toggle Virtual Instrument</span><span className="shortcut-key">V</span></div>
|
||||||
<span>Toggle Zen Mode</span>
|
<div className="help-item"><span>Toggle Mic</span><span className="shortcut-key">L</span></div>
|
||||||
<span className="shortcut-key">Z</span>
|
<div className="help-item"><span>Toggle Zen Mode</span><span className="shortcut-key">Z</span></div>
|
||||||
</div>
|
<div className="help-item"><span>Close Help / Exit Zen</span><span className="shortcut-key">Esc</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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Phrase Mode brings its own key/tempo machinery; the single-note
|
||||||
|
// key-signature select and metronome tool would be competing controls.
|
||||||
|
const inPhraseMode = settings.gameMode === 'phrase';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="controls-container">
|
<div className="controls-container">
|
||||||
{/* Top Section: Settings Grid (2 Columns) */}
|
{/* Top Section: Settings Grid (2 Columns) */}
|
||||||
@@ -124,6 +128,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 2, Col 2 */}
|
{/* Row 2, Col 2 */}
|
||||||
|
{!inPhraseMode && (
|
||||||
<div className="control-group">
|
<div className="control-group">
|
||||||
<label className="control-label">
|
<label className="control-label">
|
||||||
<span>Key signature</span>
|
<span>Key signature</span>
|
||||||
@@ -150,6 +155,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Row 3: Fret Range (Full Width) */}
|
{/* Row 3: Fret Range (Full Width) */}
|
||||||
{currentInstrumentDef.ranges.find(r => r.id === settings.difficulty)?.type === 'custom_fret' && (
|
{currentInstrumentDef.ranges.find(r => r.id === settings.difficulty)?.type === 'custom_fret' && (
|
||||||
@@ -188,7 +194,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Section: Tools Grid (2 Columns now) */}
|
{/* Bottom Section: Tools Grid (2 Columns now) */}
|
||||||
<div className="tools-grid">
|
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
|
||||||
{/* Tool 1: Tuner */}
|
{/* Tool 1: Tuner */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
@@ -214,7 +220,8 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool 2: Metronome */}
|
{/* Tool 2: Metronome (single-note modes only) */}
|
||||||
|
{!inPhraseMode && (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<label className="control-label" style={{ marginBottom: 0 }}>
|
<label className="control-label" style={{ marginBottom: 0 }}>
|
||||||
@@ -326,7 +333,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tool 3: Auto-play */}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user