Phrase mode: App integration (mode toggle, keyboard, zen), Controls tweaks, styles

This commit is contained in:
2026-09-14 00:58:43 +02:00
parent 821f88d43a
commit 4d6e205b07
3 changed files with 269 additions and 250 deletions

View File

@@ -980,4 +980,121 @@ button {
/* Ensure FretboardHint container handles flexible width generic */
.fretboard-container {
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;
}

View File

@@ -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<SingleNoteHandle>(null);
const phraseRef = useRef<PhraseHandle>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
const [hoveredMidi, setHoveredMidi] = useState<number | null>(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
</button>
<button
className={`toggle-option ${settings.gameMode === 'phrase' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'phrase' }))}
title="Play short phrases and melodies"
>
Phrases
</button>
</div>
<button
@@ -263,14 +279,14 @@ function App() {
</button>
<button
className="icon-button help-btn"
onClick={() => setShowHelp(true)}
onClick={openHelp}
title="Shortcuts Help"
>
<HelpCircle size={24} />
</button>
<button
className="icon-button"
onClick={() => setIsSettingsOpen(true)}
onClick={openSettings}
title="Settings"
>
<Settings size={24} />
@@ -280,132 +296,23 @@ function App() {
)}
<main className="main-stage">
<div className={`card sheet-music-card ${(settings.showHint || settings.showFretboard) ? 'has-hint' : ''} ${settings.zenMode ? 'zen-mode' : ''}`}>
<div className="sheet-music-container">
<SheetMusic
targetMidi={targetMidi}
// Virtual fretboard taps are explicit user input and always
// show; the mic preview is gated during speaker playback.
playedMidi={virtualNote ?? displayedPitch?.midi}
keySignature={settings.keySignature}
clef={activeClef}
transpose={activeTranspose}
width={Math.min(windowWidth - 40, 500)}
height={
activeClef === 'grand'
? (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>
{isPhrase ? (
<PhraseTrainer
ref={phraseRef}
listening={listening}
micError={error}
windowWidth={windowWidth}
/>
) : (
<SingleNoteTrainer
ref={singleNoteRef}
pitchData={pitchData}
windowWidth={windowWidth}
windowHeight={windowHeight}
/>
)}
{error && <div className="error-message">{error}</div>}
{
!settings.zenMode && (
@@ -422,7 +329,7 @@ function App() {
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
{displayedPitch ? (
<>
<span className={`detected-note ${displayedPitch.midi === targetMidi ? 'match' : ''}`}>
<span className="detected-note">
{displayedPitch.note}
</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>
<h2 style={{ marginTop: 0, marginBottom: '24px' }}>Keyboard Shortcuts</h2>
<div className="help-item">
<span>Skip Note</span>
<span className="shortcut-key">Space</span>
</div>
<div className="help-item">
<span>Toggle Hint</span>
<span className="shortcut-key">H</span>
</div>
<div className="help-item">
<span>Toggle Virtual Instrument</span>
<span className="shortcut-key">V</span>
</div>
<div className="help-item">
<span>Toggle Mic</span>
<span className="shortcut-key">L</span>
</div>
<div className="help-item">
<span>Toggle Zen Mode</span>
<span className="shortcut-key">Z</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>
{isPhrase ? (
<>
<div className="help-item"><span>Start / Pause / Resume</span><span className="shortcut-key">Space</span></div>
<div className="help-item"><span>Preview phrase (Play / Stop)</span><span className="shortcut-key">P</span></div>
<div className="help-item"><span>Retry phrase</span><span className="shortcut-key">R</span></div>
<div className="help-item"><span>New melody / next bars</span><span className="shortcut-key">N</span></div>
<div className="help-item"><span>Skip note (at your pace)</span><span className="shortcut-key">S</span></div>
</>
) : (
<>
<div className="help-item"><span>Skip Note</span><span className="shortcut-key">Space</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>Toggle Hint</span><span className="shortcut-key">H</span></div>
<div className="help-item"><span>Toggle Virtual Instrument</span><span className="shortcut-key">V</span></div>
<div className="help-item"><span>Toggle Mic</span><span className="shortcut-key">L</span></div>
<div className="help-item"><span>Toggle Zen Mode</span><span className="shortcut-key">Z</span></div>
<div className="help-item"><span>Close Help / Exit Zen</span><span className="shortcut-key">Esc</span></div>
</div>
</div>
)

View File

@@ -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 (
<div className="controls-container">
{/* Top Section: Settings Grid (2 Columns) */}
@@ -124,6 +128,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
</div>
{/* Row 2, Col 2 */}
{!inPhraseMode && (
<div className="control-group">
<label className="control-label">
<span>Key signature</span>
@@ -150,6 +155,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
</optgroup>
</select>
</div>
)}
{/* 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<ControlsProps> = ({ currentPitch }) => {
</div>
{/* Bottom Section: Tools Grid (2 Columns now) */}
<div className="tools-grid">
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
{/* 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', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -214,7 +220,8 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
)}
</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', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label" style={{ marginBottom: 0 }}>
@@ -326,7 +333,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
)}
</div>
{/* Tool 3: Auto-play */}
)}
</div>
</div>