From 4d6e205b07ffc042fd45cb511e669d53c090be78 Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Mon, 14 Sep 2026 00:58:43 +0200 Subject: [PATCH] Phrase mode: App integration (mode toggle, keyboard, zen), Controls tweaks, styles --- src/App.css | 119 ++++++++++- src/App.tsx | 387 +++++++++++++----------------------- src/components/Controls.tsx | 13 +- 3 files changed, 269 insertions(+), 250 deletions(-) diff --git a/src/App.css b/src/App.css index aa0ce02..1a3fd90 100644 --- a/src/App.css +++ b/src/App.css @@ -980,4 +980,121 @@ button { /* Ensure FretboardHint container handles flexible width generic */ .fretboard-container { width: 100%; -} \ No newline at end of file +} +/* ------------------------- 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; +} diff --git a/src/App.tsx b/src/App.tsx index 4118c07..9c36123 100644 --- a/src/App.tsx +++ b/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 { LandscapeSuggestion } from './components/LandscapeSuggestion'; - - -import { SheetMusic } from './components/SheetMusic'; import { Controls } from './components/Controls'; import { SettingsModal } from './components/SettingsModal'; 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 { useAudioPlayer } from './hooks/useAudioPlayer'; import { useSettings } from './context/useSettings'; -import { useGameLogic } from './hooks/useGameLogic'; import { audioEngine } from './audio/AudioEngine'; -import { getNoteDetails, midiToFrequency } from './music/NoteUtils'; -import { - TUNINGS, - getFretboardPositions, -} from './music/Tunings'; +import { midiToFrequency } from './music/NoteUtils'; +import { TUNINGS } from './music/Tunings'; 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 './styles/skip-button.css'; - - - function App() { const [listening, setListening] = useState(false); - const { playNote } = useAudioPlayer(); const { settings, updateSettings } = useSettings(); - // Alias to keep existing code working with minimal changes const setSettings = updateSettings; + const isPhrase = settings.gameMode === 'phrase'; + const currentTuning = TUNINGS[settings.tuningId]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; @@ -59,26 +48,15 @@ function App() { midiToFrequency(instrumentMinMidi - 1) ); - // Game Logic Hook (matching uses the raw detection; display is gated below) - const { - 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. + // Live feedback shows only what the USER plays, gated while the app's own + // speaker output is audible (see SingleNoteTrainer / PhraseTrainer). const displayedPitch = audioEngine.isAudible() ? null : pitchData; + const singleNoteRef = useRef(null); + const phraseRef = useRef(null); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false); - const [hoveredMidi, setHoveredMidi] = useState(null); const [isFullscreen, setIsFullscreen] = useState(false); const [windowWidth, setWindowWidth] = useState(window.innerWidth); const [windowHeight, setWindowHeight] = useState(window.innerHeight); @@ -92,11 +70,8 @@ function App() { return () => window.removeEventListener('resize', handleResize); }, []); - // 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. + // Theme support — MUST be a layout effect: children read the resolved theme + // colors from getComputedStyle inside their own passive effects. useLayoutEffect(() => { const root = document.documentElement; 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 */ 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(() => { const handleKeyDown = (e: KeyboardEvent) => { - // Ignore if typing in an input - if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + const target = e.target as HTMLElement | null; + 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') { 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()) { case 'h': setSettings(s => ({ ...s, showHint: !s.showHint })); @@ -158,9 +182,9 @@ function App() { break; case 'r': // Replay case 'p': // Play - playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5); + singleNoteRef.current?.playCurrent(); break; - case 'm': // Toggle Game Mode + case 'm': // Cycle game mode setSettings(s => ({ ...s, gameMode: s.gameMode === 'sight_reading' ? 'ear_training' : 'sight_reading' @@ -174,46 +198,31 @@ function App() { break; } - // Check non-character keys via code to avoid layout issues for function keys if (e.code === 'Space' || e.code === 'Enter') { - generateNewNote(); - } - - if (e.code === 'Escape') { - if (showHelp) setShowHelp(false); - else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false })); + singleNoteRef.current?.skipNote(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - // `settings` (not individual fields): the handler reads several settings - // (gameMode, referenceNoteDuration, ...) and re-attaching a listener is cheap. - }, [settings, showHelp, generateNewNote, playNote, targetMidi, setSettings]); + }, [settings, showHelp, isPhrase, setSettings]); - // Hint text construction - const hintPositions = useMemo(() => { - // If showing full hint OR detecting via fretboard, we need positions? - // Actually, showHint=true renders the red dots. - // If showFretboard=true but showHint=false, we render empty board (interactive). + // 'm' cycles all three modes from phrase mode too. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key.toLowerCase() !== 'm') return; + 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 []; - 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 + // Auto-enable mic when the tuner is turned on (rAF defers the state update // out of the effect phase to avoid cascading renders) useEffect(() => { if (settings.showTuningMeter && !listening) { @@ -245,6 +254,13 @@ function App() { > Ear Training + - )} - - - - - - - - )} - + {isPhrase ? ( + + ) : ( + + )} + {error &&
{error}
} { !settings.zenMode && ( @@ -422,7 +329,7 @@ function App() {
{displayedPitch ? ( <> - + {displayedPitch.note} {Math.round(displayedPitch.frequency)} Hz @@ -471,38 +378,26 @@ function App() {

Keyboard Shortcuts

-
- Skip Note - Space -
-
- Toggle Hint - H -
-
- Toggle Virtual Instrument - V -
-
- Toggle Mic - L -
-
- Toggle Zen Mode - Z -
-
- Replay Note - R -
-
- Toggle Game Mode - M -
-
- Close Help / Exit Zen - Esc -
+ {isPhrase ? ( + <> +
Start / Pause / ResumeSpace
+
Preview phrase (Play / Stop)P
+
Retry phraseR
+
New melody / next barsN
+
Skip note (at your pace)S
+ + ) : ( + <> +
Skip NoteSpace
+
Replay NoteR
+
Toggle Game ModeM
+ + )} +
Toggle HintH
+
Toggle Virtual InstrumentV
+
Toggle MicL
+
Toggle Zen ModeZ
+
Close Help / Exit ZenEsc
) diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index 229f9b7..6c1b99e 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -58,6 +58,10 @@ export const Controls: React.FC = ({ 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 (
{/* Top Section: Settings Grid (2 Columns) */} @@ -124,6 +128,7 @@ export const Controls: React.FC = ({ currentPitch }) => {
{/* Row 2, Col 2 */} + {!inPhraseMode && (
+ )} {/* Row 3: Fret Range (Full Width) */} {currentInstrumentDef.ranges.find(r => r.id === settings.difficulty)?.type === 'custom_fret' && ( @@ -188,7 +194,7 @@ export const Controls: React.FC = ({ currentPitch }) => { {/* Bottom Section: Tools Grid (2 Columns now) */} -
+
{/* Tool 1: Tuner */}
@@ -214,7 +220,8 @@ export const Controls: React.FC = ({ currentPitch }) => { )}
- {/* Tool 2: Metronome */} + {/* Tool 2: Metronome (single-note modes only) */} + {!inPhraseMode && (
- {/* Tool 3: Auto-play */} + )}