diff --git a/src/App.css b/src/App.css index cc43cf1..72a4f7f 100644 --- a/src/App.css +++ b/src/App.css @@ -1040,9 +1040,9 @@ button { } .phrase-setup { - margin-top: 16px; - border-top: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent); - padding-top: 12px; + margin-bottom: 16px; + border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent); + padding-bottom: 12px; } .phrase-setup summary { @@ -1126,3 +1126,83 @@ button { justify-content: safe center; justify-content: center; } + +/* ---- Assist Mode ------------------------------------------------------- */ +/* Primitive practice companion: big detected note + live staff + optional + fretboard with alternative positions. No trainer actions. */ + +.assist-card { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-md); + padding: var(--spacing-lg); +} + +.assist-note-area { + display: flex; + align-items: center; + justify-content: center; + min-height: 110px; +} + +.assist-note-name { + font-size: 5rem; + font-weight: 800; + line-height: 1; + color: var(--color-text-main); + font-variant-emoji: text; +} + +.assist-note-name.placeholder { + color: var(--color-text-muted); + opacity: 0.5; +} + +.assist-fretboard { + width: 100%; + display: flex; + justify-content: center; + overflow-x: auto; +} + +.assist-hint { + font-size: 12px; + color: var(--color-text-muted); + margin: 0; + text-align: center; +} + +/* ---- Transport step group (preview stepping) ---------------------------- */ +.transport-step-group { + display: flex; + gap: 6px; +} + +.step-button { + padding: 0.4rem 0.55rem; +} + +/* ---- Display toggles (virtual instrument / hint show-hide) -------------- */ +/* Separate row of pure show/hide switches, styled like the tuner and + metronome switches — visually distinct from transport actions. */ +.display-toggles { + display: flex; + justify-content: center; + gap: var(--spacing-lg); + margin-top: 10px; +} + +.display-toggle { + display: flex; + align-items: center; + gap: 8px; +} + +.display-toggle-label { + display: flex; + align-items: center; + gap: 5px; + font-size: 12px; + color: var(--color-text-muted); +} diff --git a/src/App.tsx b/src/App.tsx index ec5f151..7d2b771 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { SingleNoteTrainer, type SingleNoteHandle } from './components/SingleNot import { LiveNoteStaff } from './components/LiveNoteStaff'; import { resolveClefTranspose } from './music/InstrumentConfigs'; import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer'; +import { AssistMode } from './components/AssistMode'; import { usePitchDetector } from './hooks/usePitchDetector'; import { useSettings } from './context/useSettings'; import { audioEngine } from './audio/AudioEngine'; @@ -26,6 +27,7 @@ function App() { const setSettings = updateSettings; const isPhrase = settings.gameMode === 'phrase'; + const isAssist = settings.gameMode === 'assist'; const currentTuning = TUNINGS[settings.tuningId]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; @@ -121,24 +123,28 @@ function App() { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; - const onInteractive = + // Text inputs / selects keep their native keys — shortcuts must not + // fire while typing. FOCUSED BUTTONS however stay eligible: after a + // click the button keeps focus, and gating there made N/R/P/L + // silently dead until the user clicked elsewhere. Space/Enter on a + // focused button activate it natively (guarded below), so no + // double-fire. + const onTextEntry = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || - target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || !!target?.isContentEditable; + if (onTextEntry) return; 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') { + // Prevent default for space to stop scrolling (Space on a focused + // button activates it natively — preventDefault only applies when the + // focus is on the body, where the shortcut below takes over). + if (e.code === 'Space' && !(target instanceof HTMLButtonElement)) { e.preventDefault(); } @@ -159,6 +165,12 @@ function App() { case 'n': phraseRef.current?.next(); break; + case 'arrowleft': + phraseRef.current?.previewStep(-1); + break; + case 'arrowright': + phraseRef.current?.previewStep(1); + break; case 's': phraseRef.current?.skip(); break; @@ -169,12 +181,33 @@ function App() { setListening(l => !l); break; } - if (e.code === 'Space' || e.code === 'Enter') { + // Space/Enter on a focused button natively activate that button + // (e.g. Start); only treat them as pause-toggle when NOT on a button. + if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) { phraseRef.current?.pauseToggle(); } return; } + if (isAssist) { + // Assist mode: only display shortcuts — no trainer actions. + switch (e.key.toLowerCase()) { + case 'h': + setSettings(s => ({ ...s, showHint: !s.showHint })); + break; + case 'z': + setSettings(s => ({ ...s, zenMode: !s.zenMode })); + break; + case 'v': + setSettings(s => ({ ...s, showFretboard: !s.showFretboard })); + break; + case 'l': + setListening(l => !l); + break; + } + return; + } + switch (e.key.toLowerCase()) { case 'h': setSettings(s => ({ ...s, showHint: !s.showHint })); @@ -200,23 +233,23 @@ function App() { break; } - if (e.code === 'Space' || e.code === 'Enter') { + if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) { singleNoteRef.current?.skipNote(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [settings, showHelp, isPhrase, setSettings]); + }, [settings, showHelp, isPhrase, isAssist, setSettings]); - // 'm' cycles all three modes from phrase mode too. + // 'm' cycles back to sight reading from phrase/assist modes. 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') { + if (settings.gameMode === 'phrase' || settings.gameMode === 'assist') { setSettings(s => ({ ...s, gameMode: 'sight_reading' })); } }; @@ -263,6 +296,13 @@ function App() { > Phrases + - - {/* Target-note filter: unified controls, all modes */} - + )} + {shouldShowFilters && } {/* Bottom Section: Tools Grid (2 Columns now) */}
diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 79f81e5..b3c958f 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -13,7 +13,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe import { useSettings } from '../context/useSettings'; import { useAudioPlayer } from '../hooks/useAudioPlayer'; import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer'; -import { getPhraseSettings, getPracticeFilter, type PhraseSettings, type PracticeFilter } from '../types/SettingsTypes'; +import { getPhraseSettings, getPracticeFilter, type PhraseSettings } from '../types/SettingsTypes'; import { rawFrameCount } from '../hooks/rawFrameBus'; import { audioEngine } from '../audio/AudioEngine'; import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow } from '../music/playableRange'; @@ -28,17 +28,19 @@ import { getNoteDetails } from '../music/NoteUtils'; import { EXERCISES, loadExercise } from '../exercises/library'; import { parseAbc } from '../exercises/abcParser'; import { PhraseSheetMusic } from './PhraseSheetMusic'; +import { TargetNoteControls } from './TargetNoteControls'; import { Fretboard } from './Fretboard'; import { PianoKeys } from './PianoKeys'; import { TUNINGS, getFretboardPositions } from '../music/Tunings'; import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs'; const AUTO_CONTINUE_DELAY_MS = 1500; -import { Play, Pause, RotateCcw, SkipForward, Volume2, Square, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react'; +import { Play, Pause, RotateCcw, SkipForward, SkipBack, Volume2, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react'; export interface PhraseHandle { pauseToggle: () => void; previewToggle: () => void; + previewStep: (dir: 1 | -1) => void; retry: () => void; skip: () => void; next: () => void; @@ -71,14 +73,9 @@ export const PhraseTrainer = forwardRef( const setSettings = updateSettings; const { playNote } = useAudioPlayer(); const phrase = getPhraseSettings(settings); - // Unified target-note controls: Phrase Mode reads/writes the shared - // practice filter so all modes expose identical key/fret-window state. + // Unified target-note controls: Phrase Mode reads the shared practice + // filter (single source of truth across all modes). const pf = getPracticeFilter(settings); - phrase.keyTonic = pf.keyTonic; - phrase.keyMode = pf.keyMode; - phrase.fretWindowEnabled = pf.fretWindowEnabled; - phrase.fretMin = pf.fretMin; - phrase.fretMax = pf.fretMax; // Unified metronome: phrase tempo reads from the shared rhythm BPM // (single source of truth across modes; the phrase-local BPM control // was replaced by the common metronome widget in Controls). @@ -96,18 +93,7 @@ export const PhraseTrainer = forwardRef( ? settings.rhythm.active && settings.rhythm.sound : settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise; const setPhrase = useCallback((updates: Partial) => { - updateSettings(s => { - const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } }; - // Mirror shared filter fields into the unified practice filter. - const shared: Partial = {}; - if ('keyTonic' in updates) shared.keyTonic = updates.keyTonic; - if ('keyMode' in updates) shared.keyMode = updates.keyMode; - if ('fretWindowEnabled' in updates) shared.fretWindowEnabled = updates.fretWindowEnabled; - if ('fretMin' in updates) shared.fretMin = updates.fretMin; - if ('fretMax' in updates) shared.fretMax = updates.fretMax; - if (Object.keys(shared).length > 0) next.practice = { ...getPracticeFilter(s), ...shared }; - return next; - }); + updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } })); }, [updateSettings]); const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentTuning = TUNINGS[settings.tuningId]; @@ -140,7 +126,6 @@ export const PhraseTrainer = forwardRef( customMaxFret: settings.customMaxFret, }); }, [pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, fretted, currentTuning, - phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax, settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret]); // ---- Imported score (session-only) -------------------------------------- @@ -173,8 +158,8 @@ export const PhraseTrainer = forwardRef( const baseScore: Result | null = useMemo(() => { if (phrase.material === 'melody') { const result = generateMelody({ - keyTonic: phrase.keyTonic, - keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, + keyTonic: pf.keyTonic, + keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId, bars: phrase.bars, meter: { numerator: phrase.meterNumerator, denominator: 4 }, rhythmLevel: phrase.rhythmLevel, @@ -184,8 +169,8 @@ export const PhraseTrainer = forwardRef( } if (phrase.material === 'scale') { return generateScaleDrill({ - keyTonic: phrase.keyTonic, - keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, + keyTonic: pf.keyTonic, + keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId, direction: phrase.scaleDirection, coverage: phrase.scaleCoverage, meter: { numerator: phrase.meterNumerator, denominator: 4 }, @@ -196,8 +181,8 @@ export const PhraseTrainer = forwardRef( // Meter is auto-derived inside the generator from the figure // length (always eighths) — the user no longer picks a meter. return generateGiulianiStudy({ - keyTonic: phrase.keyTonic, - keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, + keyTonic: pf.keyTonic, + keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId, pattern: phrase.giulianiPattern, tuningId: settings.tuningId, bars: phrase.giulianiBars, @@ -209,8 +194,8 @@ export const PhraseTrainer = forwardRef( } if (phrase.material === 'arpeggio') { return generateArpeggio({ - keyTonic: phrase.keyTonic, - keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, + keyTonic: pf.keyTonic, + keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId, degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I', pattern: phrase.arpeggioPattern, coverage: phrase.arpeggioCoverage, @@ -231,7 +216,7 @@ export const PhraseTrainer = forwardRef( return { ok: true, value: importedScore }; } return null; - }, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator, + }, [phrase.material, pf.keyTonic, pf.keyMode, phrase.bars, phrase.meterNumerator, phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId, phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage, phrase.arpeggioBars, phrase.arpeggioProgression, @@ -323,9 +308,7 @@ export const PhraseTrainer = forwardRef( }, [scoreOk, fixedMaterial, pool, fullScore]); const nextAction = useCallback(() => { - if (phrase.material === 'melody' || (phrase.material === 'arpeggio' && phrase.arpeggioBars > 1)) { - setMelodySeed(s => (s + 1) % 999983); - } else if (fixedMaterial && scoreOk) { + if (fixedMaterial && scoreOk) { const total = fullScore!.value.measures.length; const nextStart = phrase.startBar + phrase.barCount; if (nextStart > total) { @@ -333,6 +316,12 @@ export const PhraseTrainer = forwardRef( } else { setPhrase({ startBar: nextStart }); } + return; + } + // Seeded material: roll a new variation (giuliani and single-chord + // arpeggios included — they take melodySeed too). + if (phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani') { + setMelodySeed(s => (s + 1) % 999983); } }, [phrase.material, phrase.startBar, phrase.barCount, fixedMaterial, scoreOk, fullScore, setPhrase]); @@ -340,6 +329,7 @@ export const PhraseTrainer = forwardRef( useImperativeHandle(ref, () => ({ pauseToggle: () => trainer?.pauseToggle(), previewToggle: () => trainer?.previewToggle(), + previewStep: (dir) => trainer?.previewStep(dir), retry: () => trainer?.retry(), skip: () => trainer?.skip(), pause: () => trainer?.pause(), @@ -349,17 +339,22 @@ export const PhraseTrainer = forwardRef( // ---- Hints -------------------------------------------------------------- const activeScore = trainer.activeScore; const events = scoreEvents(activeScore); - const currentMidi = trainer.currentIdx >= 0 && events[trainer.currentIdx]?.pitch - ? events[trainer.currentIdx]!.pitch!.midi + // While previewing, the hint instruments follow the preview cursor so + // the user can find notes on the fretboard while listening. + const displayIdx = trainer.phase === 'preview' || trainer.phase === 'previewPaused' + ? trainer.previewIdx + : trainer.currentIdx; + const currentMidi = displayIdx >= 0 && events[displayIdx]?.pitch + ? events[displayIdx]!.pitch!.midi : null; const hintPositions = useMemo(() => { if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning || currentMidi === null) return []; - if (phrase.fretWindowEnabled) { - return positionsWithinWindow(currentMidi, currentTuning, phrase.fretMin, phrase.fretMax); + if (pf.fretWindowEnabled) { + return positionsWithinWindow(currentMidi, currentTuning, pf.fretMin, pf.fretMax); } return getFretboardPositions(currentMidi, currentTuning); - }, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax]); + }, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, pf.fretWindowEnabled, pf.fretMin, pf.fretMax]); const handleVirtualPlay = useCallback((midi: number) => { if (!settings.virtualGuitarMute) { @@ -380,7 +375,8 @@ export const PhraseTrainer = forwardRef( const statusText = useMemo(() => { if (!scoreOk) return materialError ?? 'No material.'; const p = trainer.phase; - if (p === 'preview') return 'Listen …'; + if (p === 'preview') return 'Listen … (pause to step note by note)'; + if (p === 'previewPaused') return 'Paused — step with ⏮ ⏭ or ← →, resume with P'; if (p === 'countIn') return `Count-in: ${trainer.countInLeft}`; if (p === 'paused') return 'Paused'; if (p === 'done') { @@ -399,12 +395,13 @@ export const PhraseTrainer = forwardRef( const p = trainer.phase; if (p === 'playing' || p === 'countIn') return 'Pause'; if (p === 'paused') return 'Resume'; - if (p === 'preview') return 'Stop preview'; + if (p === 'preview') return 'Pause'; + if (p === 'previewPaused') return 'Resume'; return 'Start'; }, [trainer.phase]); const startAction = useMemo(() => { - if (trainer.phase === 'preview') return trainer.previewToggle; + if (trainer.phase === 'preview' || trainer.phase === 'previewPaused') return trainer.previewToggle; return trainer.pauseToggle; }, [trainer]); @@ -452,118 +449,8 @@ export const PhraseTrainer = forwardRef(
-
- {scoreOk ? ( - - ) : ( -
{materialError}
- )} -
- - {trainer.error &&
{trainer.error}
} - {importError &&
{importError}
} - {rangeWarning &&
{rangeWarning}
} - -
- {trainer.phase === 'countIn' && ( - {trainer.countInLeft} - )} -
{statusText}
-
- - {/* Transport — reuses the single-note button styles/behavior */} -
- - {!settings.zenMode && ( - <> - - - {(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && ( - - )} - {phrase.pace === 'step' && ( - - )} - - )} - {/* Same virtual-instrument / hint toggles as single-note mode */} - {trainer.phase === 'done' && settings.zenMode && ( - <> - - {(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && ( - - )} - - )} - {!settings.zenMode && currentInstrumentDef.showTuning && ( - - )} - {!settings.zenMode && ( - - )} -
- - {/* Setup panel */} + {/* Setup panel — above the notation, holds material options + plus the shared note filter (key/strings/fret window) */} {!settings.zenMode && (
Setup @@ -579,12 +466,6 @@ export const PhraseTrainer = forwardRef( - {(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && ( - - Key/mode: use the "Target key" controls below the sheet — shared across modes. - - )} - {phrase.material === 'melody' && ( <> )} - {fretted && ( - - Fret window: use the "Fret window" controls below the sheet — shared across modes. - - )} - {/* Pace lives in the metronome widget now: 'Sync to exercise' checked = tempo pace, unchecked = at-your-pace (step). */} @@ -816,11 +691,6 @@ export const PhraseTrainer = forwardRef( /> Virtual input only - {phrase.pace === 'tempo' && ( - - BPM + click: on the metronome widget below. - - )} {phrase.pace === 'tempo' && ( -

The run clicks a full count-in bar, then one click per beat. The metronome pendulum mirrors these beats — set the tempo on the metronome tool.

+

Tempo and click sound live on the metronome tool (Sync checked = the run sets the pace; unchecked = play at your own pace with a free metronome).

)}

Matching checks pitch, not fingering — several fretboard positions produce the same pitch. For repeated identical notes, briefly release between notes so each attack is detected.

+
)} +
+ {scoreOk ? ( + + ) : ( +
{materialError}
+ )} +
+ + {trainer.error &&
{trainer.error}
} + {importError &&
{importError}
} + {rangeWarning &&
{rangeWarning}
} + +
+ {trainer.phase === 'countIn' && ( + {trainer.countInLeft} + )} +
{statusText}
+
+ + {/* Transport — reuses the single-note button styles/behavior */} +
+ + {!settings.zenMode && ( + <> + {trainer.phase === 'previewPaused' && ( +
+ + +
+ )} + + + {(phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani' || fixedMaterial) && ( + + )} + {phrase.pace === 'step' && ( + + )} + + )} + {/* Same virtual-instrument / hint toggles as single-note mode */} + {trainer.phase === 'done' && settings.zenMode && ( + <> + + {(phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani' || fixedMaterial) && ( + + )} + + )} +
+ + {/* Display toggles — separate row, switch style (like the + tuner/metronome switches): pure show/hide, no run state. */} + {!settings.zenMode && ( +
+ {currentInstrumentDef.showTuning && ( +
+ + + {currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'} + + +
+ )} +
+ + + Hint + + +
+
+ )} + + {/* Hint instruments */} {(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
@@ -878,7 +901,7 @@ export const PhraseTrainer = forwardRef( onPlayNote={handleVirtualPlay} onHover={setHoveredMidi} showHints={settings.showHint} - maxFrets={Math.max(15, phrase.fretWindowEnabled ? phrase.fretMax + 1 : 0)} + maxFrets={Math.max(15, pf.fretWindowEnabled ? pf.fretMax + 1 : 0)} /> ) )} diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 25cd710..a960f3b 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,8 +1,32 @@ import React, { useState } from 'react'; -import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor, Clock } from 'lucide-react'; +import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor, Clock, Guitar } from 'lucide-react'; import type { AppSettings } from '../types/SettingsTypes'; import { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig'; import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector'; +import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; +import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; + +/** Collapsible settings section. First section open by default. */ +const Section: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = false, children }) => { + const [open, setOpen] = useState(defaultOpen); + return ( +
+ + {open &&
{children}
} +
+ ); +}; interface SettingsModalProps { isOpen: boolean; @@ -78,7 +102,52 @@ export const SettingsModal: React.FC = ({
e.stopPropagation()}> -

Settings

+

Settings

+ + {/* Instrument & Tuning (rarely changed — lives here now) */} +
+
+ + +
+ {INSTRUMENT_DEFINITIONS[settings.instrument]?.showTuning && INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] && ( +
+ + +
+ )} +
+ +
{/* Theme Settings */}
@@ -119,8 +188,6 @@ export const SettingsModal: React.FC = ({
-
- {/* Animation Settings */}
@@ -140,7 +207,9 @@ export const SettingsModal: React.FC = ({

-
+ + +
@@ -261,7 +330,9 @@ export const SettingsModal: React.FC = ({

-
+
+ +
{/* Microphone Toggle */}
@@ -434,6 +505,8 @@ export const SettingsModal: React.FC = ({ )}
)} + +
diff --git a/src/components/TargetNoteControls.tsx b/src/components/TargetNoteControls.tsx index 5df73d7..2d82f13 100644 --- a/src/components/TargetNoteControls.tsx +++ b/src/components/TargetNoteControls.tsx @@ -6,8 +6,10 @@ * - Strings: pick which strings contribute target notes (fretted instruments). * - Fret window: restrict target notes to a fret range (fretted instruments). * - * In Phrase Mode the same controls bind to the shared practice filter - * (mirrored into phrase settings), so state stays consistent across modes. + * In Phrase Mode the Key section binds to the generator key (tonic+mode are + * always applied; no toggle), and the display-signature sync checkbox is + * hidden (phrase notation takes its signature from the score's key). + * Rendered inside the per-mode Setup panel above the notation. */ import { useMemo } from 'react'; import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react'; @@ -46,6 +48,8 @@ const MINOR_TONIC_TO_SIGNATURE: Record = { export const TargetNoteControls: React.FC = () => { const { settings, updateSettings } = useSettings(); const pf = getPracticeFilter(settings); + // Phrase Mode: key tonic/mode are the generator key — always in effect. + const inPhraseMode = settings.gameMode === 'phrase'; const setFilter = (updates: Partial) => { updateSettings(s => { @@ -81,7 +85,8 @@ export const TargetNoteControls: React.FC = () => { const allSelected = pf.strings.length === 0; - const anyActive = pf.keyEnabled || pf.fretWindowEnabled || pf.strings.length > 0; + const anyActive = + (!inPhraseMode && pf.keyEnabled) || pf.fretWindowEnabled || pf.strings.length > 0; return (
{/* Section header: unified with Tuner/Metronome style */} @@ -108,15 +113,17 @@ export const TargetNoteControls: React.FC = () => { Key - + {!inPhraseMode && ( + + )}
- {pf.keyEnabled && ( + {(pf.keyEnabled || inPhraseMode) && ( <>
- + {!inPhraseMode && ( + + )} )}
diff --git a/src/hooks/usePhraseTrainer.ts b/src/hooks/usePhraseTrainer.ts index 9bc6da3..e0fbce9 100644 --- a/src/hooks/usePhraseTrainer.ts +++ b/src/hooks/usePhraseTrainer.ts @@ -21,7 +21,7 @@ import { subscribeRawFrames } from './rawFrameBus'; import { phraseRunBus } from './phraseRunBus'; import { phraseBeatBus } from './phraseBeatBus'; -export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | 'done'; +export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | 'previewPaused' | 'done'; export interface PhraseSelection { readonly startBar: number; @@ -56,6 +56,8 @@ const REARM_AFTER_SUSTAINED_MS = 2500; export interface PhraseTrainerApi { readonly phase: PhrasePhase; + /** Event index the preview cursor points at (-1 when not previewing). */ + readonly previewIdx: number; /** Active slice the user is currently working through (may be a resume tail). */ readonly activeScore: Score; readonly statuses: NoteStatus[]; @@ -76,6 +78,9 @@ export interface PhraseTrainerApi { retry: () => void; skip: () => void; previewToggle: () => void; + /** While previewing: step one event forward/backward (player-style). + * Plays the note at the new position and moves the preview indicator. */ + previewStep: (dir: 1 | -1) => void; virtualTap: (midi: number) => void; /** External pause (settings dialog, visibility change handled internally). */ pause: () => void; @@ -95,6 +100,7 @@ export function usePhraseTrainer( const [countInLeft, setCountInLeft] = useState(0); const [error, setError] = useState(null); const [repeatedNoteBlocked, setRepeatedNoteBlocked] = useState(false); + const [previewIdx, setPreviewIdx] = useState(-1); const [accumulated, setAccumulated] = useState({ total: 0, matched: 0, missed: 0, skipped: 0 }); // ---- Active slice (resume tails replace it) --------------------------- @@ -102,6 +108,8 @@ export function usePhraseTrainer( const matcherRef = useRef(null); const repeatBlockedRef = useRef(false); + const previewIdxRef = useRef(-1); + const previewToggleRef = useRef<(() => void) | null>(null); const phaseRef = useRef('ready'); const paceRef = useRef(config.pace); const configRef = useRef(config); @@ -117,6 +125,10 @@ export function usePhraseTrainer( const runTokenRef = useRef(0); const repeatTimerRef = useRef | null>(null); const previewTimerRef = useRef | null>(null); + /** Audio time the preview cursor is scheduled to reach (for pause). */ + const previewEndAtRef = useRef(0); + /** Audio-time interval that advances the preview cursor. */ + const previewTickRef = useRef | null>(null); const events = useMemo(() => scoreEvents(activeScore), [activeScore]); @@ -157,6 +169,9 @@ export function usePhraseTrainer( setAccumulated({ total: 0, matched: 0, missed: 0, skipped: 0 }); audioEngine.cancelGroup(PREVIEW_GROUP); audioEngine.cancelGroup(CLICK_GROUP); + if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; } + previewIdxRef.current = -1; + setPreviewIdx(-1); setPhaseBoth('ready'); setCurrentIdx(-1); setCountInLeft(0); @@ -388,6 +403,15 @@ export function usePhraseTrainer( const pauseInternalRef = useRef<(() => void) | null>(null); const pause = useCallback(() => { + if (phaseRef.current === 'preview') { + // External pause during preview behaves like preview pause + // (cursor kept, stepping enabled) but without phase recursion. + audioEngine.cancelGroup(PREVIEW_GROUP); + if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; } + if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; } + setPhaseBoth('previewPaused'); + return; + } if (phaseRef.current !== 'playing' && phaseRef.current !== 'countIn') return; if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; } audioEngine.cancelGroup(CLICK_GROUP); @@ -462,7 +486,7 @@ export function usePhraseTrainer( if (paceRef.current === 'tempo') resumeTempo(); else setPhaseBoth('playing'); } else if (p === 'ready' || p === 'done') start(); - // preview: handled by previewToggle + else if (p === 'preview' || p === 'previewPaused') previewToggleRef.current?.(); }, [pause, resumeTempo, setPhaseBoth, start]); const retry = useCallback(() => { @@ -485,46 +509,115 @@ export function usePhraseTrainer( advanceStep(m); }, [advanceStep]); - const previewToggle = useCallback(() => { - if (phaseRef.current === 'preview') { - audioEngine.cancelGroup(PREVIEW_GROUP); - if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; } - setPhaseBoth('ready'); - return; - } - pause(); - runTokenRef.current++; - const token = runTokenRef.current; + /** Preview from event `fromIdx` to the end. A cursor interval mirrors + * playback time so the notation indicator follows the sounding note. */ + const playPreviewFrom = useCallback((fromIdx: number, token: number) => { void audioEngine.ensureRunning().then(() => { if (runTokenRef.current !== token) return; const bpm = sanitizeBpm(configRef.current.bpm); const spb = 60 / bpm; const t0 = audioEngine.now() + 0.15; const vol = configRef.current.previewVolume ?? 0.4; + const startTick = fromIdx > 0 ? events[fromIdx].startTick : 0; + const totalTicks = events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0); + const secPerTick = spb / PPQ; for (const e of events) { if (!e.pitch) continue; - audioEngine.scheduleNote(e.pitch.midi, t0 + (e.startTick / PPQ) * spb, (e.durationTicks / PPQ) * spb, vol, PREVIEW_GROUP); + if (e.startTick < startTick) continue; + audioEngine.scheduleNote(e.pitch.midi, t0 + ((e.startTick - startTick) / PPQ) * spb, (e.durationTicks / PPQ) * spb, vol, PREVIEW_GROUP); } // Tempo pace: click along with the preview so the rhythm is // audible while listening (quarter beats over the full span). // Registered in PREVIEW_GROUP so stopping the preview also stops // its clicks (no ghost metronomes after an aborted preview). if (paceRef.current === 'tempo' && configRef.current.clickSound) { - const totalTicks = events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0); - const beats = Math.floor(totalTicks / PPQ); + const beats = Math.floor((totalTicks - startTick) / PPQ); for (let i = 0; i < beats; i++) { - audioEngine.playClickAt(t0 + i * spb, CLICK_VOLUME, i % 4 === 0, PREVIEW_GROUP); + audioEngine.playClickAt(t0 + i * spb, CLICK_VOLUME, (fromIdx + i) % 4 === 0, PREVIEW_GROUP); } } setPhaseBoth('preview'); - const totalSec = (events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0) / PPQ) * spb; - previewTimerRef.current = setTimeout(() => { - if (runTokenRef.current === token) { + + // Cursor: advance previewIdx by audio time (event start ticks). + previewEndAtRef.current = t0 + (totalTicks - startTick) * secPerTick; + if (previewTickRef.current) clearInterval(previewTickRef.current); + previewTickRef.current = setInterval(() => { + if (phaseRef.current !== 'preview' || runTokenRef.current !== token) { + if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; } + return; + } + const elapsedTicks = Math.max(0, (audioEngine.now() - t0) / secPerTick) + startTick; + let idx = previewIdxRef.current; + let advanced = false; + while (idx + 1 < events.length && events[idx + 1].startTick <= elapsedTicks) { idx++; advanced = true; } + if (advanced) { previewIdxRef.current = idx; setPreviewIdx(idx); } + if (audioEngine.now() >= previewEndAtRef.current + 0.2) { + if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; } + previewIdxRef.current = -1; + setPreviewIdx(-1); setPhaseBoth('ready'); } - }, 150 + totalSec * 1000 + 200); + }, 60); }); - }, [events, pause, setPhaseBoth]); + }, [events, setPhaseBoth]); + + const stopPreview = useCallback(() => { + audioEngine.cancelGroup(PREVIEW_GROUP); + if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; } + if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; } + previewIdxRef.current = -1; + setPreviewIdx(-1); + setPhaseBoth('ready'); + }, [setPhaseBoth]); + + /** Pause preview: stop scheduled audio, keep the cursor position. */ + const previewPause = useCallback(() => { + if (phaseRef.current !== 'preview') return; + audioEngine.cancelGroup(PREVIEW_GROUP); + if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; } + if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; } + setPhaseBoth('previewPaused'); + }, [setPhaseBoth]); + + const previewToggle = useCallback(() => { + const p = phaseRef.current; + if (p === 'preview') { + previewPause(); + return; + } + if (p === 'previewPaused') { + // Resume from the paused cursor position. + runTokenRef.current++; + const token = runTokenRef.current; + playPreviewFrom(previewIdxRef.current, token); + return; + } + pause(); + runTokenRef.current++; + const token = runTokenRef.current; + previewIdxRef.current = 0; + setPreviewIdx(0); + playPreviewFrom(0, token); + }, [pause, playPreviewFrom, previewPause, stopPreview]); + previewToggleRef.current = previewToggle; + + /** Player-style stepping — only while the preview is PAUSED: move the + * cursor one event and play that single note (find-it-on-the-neck). */ + const previewStep = useCallback((dir: 1 | -1) => { + if (phaseRef.current !== 'previewPaused') return; + const nextIdx = Math.max(0, Math.min(events.length - 1, previewIdxRef.current + dir)); + if (nextIdx === previewIdxRef.current) return; + previewIdxRef.current = nextIdx; + setPreviewIdx(nextIdx); + // Play just this one event as an audible cue. + runTokenRef.current++; + const token = runTokenRef.current; + const e = events[nextIdx]; + void audioEngine.ensureRunning().then(() => { + if (runTokenRef.current !== token || !e.pitch) return; + audioEngine.scheduleNote(e.pitch.midi, audioEngine.now() + 0.05, (e.durationTicks / PPQ) * (60 / sanitizeBpm(configRef.current.bpm)), configRef.current.previewVolume ?? 0.4, PREVIEW_GROUP); + }); + }, [events, setPhaseBoth]); const virtualTap = useCallback((midi: number) => { const p = phaseRef.current; @@ -548,6 +641,7 @@ export function usePhraseTrainer( const stop = useCallback(() => { if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; } if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; } + if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; } audioEngine.cancelGroup(PREVIEW_GROUP); audioEngine.cancelGroup(CLICK_GROUP); runTokenRef.current++; @@ -576,6 +670,7 @@ export function usePhraseTrainer( // Unmount: cancel all scheduled audio and timers. if (repeatTimerRef.current) clearTimeout(repeatTimerRef.current); if (previewTimerRef.current) clearTimeout(previewTimerRef.current); + if (previewTickRef.current) clearInterval(previewTickRef.current); audioEngine.cancelGroup(PREVIEW_GROUP); audioEngine.cancelGroup(CLICK_GROUP); }, []); @@ -602,6 +697,7 @@ export function usePhraseTrainer( statuses, currentIdx, countInLeft, + previewIdx, scoreStart: scoreStartRef.current, spb: spbRef.current, error, @@ -611,6 +707,7 @@ export function usePhraseTrainer( retry, skip, previewToggle, + previewStep, virtualTap, pause, stop, diff --git a/src/hooks/usePitchDetector.ts b/src/hooks/usePitchDetector.ts index da65228..cb9478a 100644 --- a/src/hooks/usePitchDetector.ts +++ b/src/hooks/usePitchDetector.ts @@ -9,7 +9,7 @@ import { NOTE_DISPLAY_STABLE_FRAMES, } from '../AppConfig'; -interface PitchData { +export interface PitchData { frequency: number; midi: number; note: string; diff --git a/src/styles/variables.css b/src/styles/variables.css index fac8fc6..d10f121 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -59,24 +59,24 @@ @media (prefers-color-scheme: dark) { :root { color-scheme: dark; - --color-bg: hsl(30, 20%, 10%); - --color-surface: hsl(30, 20%, 15%); - --color-text-main: hsl(30, 10%, 90%); - --color-text-muted: hsl(30, 10%, 60%); - --color-primary: hsl(0, 0%, 90%); - --color-primary-dark: hsl(0, 0%, 100%); + --color-bg: hsl(220, 6%, 10%); + --color-surface: hsl(220, 6%, 15%); + --color-text-main: hsl(220, 4%, 90%); + --color-text-muted: hsl(220, 4%, 60%); + --color-primary: hsl(220, 4%, 90%); + --color-primary-dark: hsl(220, 4%, 100%); } } /* Manual Dark Mode Override */ html[data-theme="dark"] { color-scheme: dark; - --color-bg: hsl(30, 20%, 10%); - --color-surface: hsl(30, 20%, 15%); - --color-text-main: hsl(30, 10%, 90%); - --color-text-muted: hsl(30, 10%, 60%); - --color-primary: hsl(0, 0%, 90%); - --color-primary-dark: hsl(0, 0%, 100%); + --color-bg: hsl(220, 6%, 10%); + --color-surface: hsl(220, 6%, 15%); + --color-text-main: hsl(220, 4%, 90%); + --color-text-muted: hsl(220, 4%, 60%); + --color-primary: hsl(220, 4%, 90%); + --color-primary-dark: hsl(220, 4%, 100%); } /* Manual Light Mode Override */ diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts index d0b318a..203285e 100644 --- a/src/types/SettingsTypes.ts +++ b/src/types/SettingsTypes.ts @@ -49,8 +49,6 @@ export interface PhraseSettings { material: PhraseMaterial; libraryId: string; /** Imported score id (the parsed score lives in component state, session-only). */ - keyTonic: string; - keyMode: string; // ModeId bars: number; // 1–8 (melodies) meterNumerator: 3 | 4 | 6; rhythmLevel: 1 | 2 | 3; @@ -69,10 +67,6 @@ export interface PhraseSettings { /** Giuliani study material (own category — voicing-enforced). */ giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim' | 'piai' | 'pmami' | 'pimami' | 'pimaia' | 'pimai' | 'pmia' | 'pmim'; giulianiBars: number; - /** Phrase-local fret window (guitar/bass) — overrides the note set. */ - fretWindowEnabled: boolean; - fretMin: number; - fretMax: number; /** Practice range for library/import material (1-based, inclusive). */ startBar: number; barCount: number; @@ -95,8 +89,6 @@ export interface PhraseSettings { export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = { material: 'melody', libraryId: '', - keyTonic: 'C', - keyMode: 'major', bars: 2, meterNumerator: 4, rhythmLevel: 1, @@ -111,9 +103,6 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = { arpeggioProgression: 'functional', giulianiPattern: 'pima', giulianiBars: 4, - fretWindowEnabled: false, - fretMin: 0, - fretMax: 4, startBar: 1, barCount: 2, pace: 'step', @@ -145,7 +134,7 @@ export interface AppSettings { instrument: string; rhythm: RhythmSettings; zenMode: boolean; - gameMode: 'sight_reading' | 'ear_training' | 'phrase'; + gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist'; customMinFret?: number; customMaxFret?: number; autoPlaySightReading?: boolean;