From b7c8187aef9164395208805698e20d1e90965f79 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 15 Sep 2026 21:05:44 +0000 Subject: [PATCH 1/7] UI reorg: settings grouped by usage frequency - Filter (key/strings/fret window) collapsed behind Filter button with active-count badge; opens the unified TargetNoteControls panel - Display key signature moved into the filter panel (all modes) - Instrument + Tuning selects moved into Settings modal (Instrument section); removed from footer - Settings modal restructured into collapsible sections: Instrument, Appearance, Audio, Microphone - Phrase setup panel: removed cross-reference hint texts - PracticeFilter is now the single source of truth: removed mirrored keyTonic/keyMode/fretWindow fields from PhraseSettings - Dark mode: neutral gray with slight blue tint (was warm brown) --- src/components/Controls.tsx | 143 +++++--------------------- src/components/PhraseTrainer.tsx | 70 ++++--------- src/components/SettingsModal.tsx | 85 +++++++++++++-- src/components/TargetNoteControls.tsx | 29 ++++++ src/styles/variables.css | 24 ++--- src/types/SettingsTypes.ts | 11 -- 6 files changed, 168 insertions(+), 194 deletions(-) diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index f11b553..9090d65 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -1,14 +1,11 @@ import React, { useEffect, useState } from 'react'; -import { Guitar, Music, Gauge } from 'lucide-react'; -import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; -import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; - +import { Gauge, SlidersHorizontal } from 'lucide-react'; import { TuningMeter } from './TuningMeter'; import { TargetNoteControls } from './TargetNoteControls'; import { MetronomeWidget } from './MetronomeWidget'; import { phraseRunBus } from '../hooks/phraseRunBus'; import { useSettings } from '../context/useSettings'; -import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes'; +import { getPracticeFilter, type RhythmSettings } from '../types/SettingsTypes'; @@ -18,35 +15,13 @@ interface ControlsProps { export const Controls: React.FC = ({ currentPitch }) => { const { settings, updateSettings } = useSettings(); - // Alias to minimize refactor, or just use updateSettings. const onUpdateSettings = updateSettings; // Phrase run gate: the metronome only ticks while the phrase trainer is // counting in / playing (armed by the user toggle, fired by the run). const [phraseRunActive, setPhraseRunActive] = useState(false); useEffect(() => phraseRunBus.subscribe(setPhraseRunActive), []); - const handleTuningChange = (e: React.ChangeEvent) => { - onUpdateSettings({ ...settings, tuningId: e.target.value }); - }; - - const handleInstrumentChange = (e: React.ChangeEvent) => { - const newInstrumentId = e.target.value; - const instDef = INSTRUMENT_DEFINITIONS[newInstrumentId]; - - const defaultRange = instDef.ranges[0].id; - - // Reset tuning if applicable, or just keep as is (it won't be shown/used) - let newTuningId = settings.tuningId; - if (instDef.showTuning && INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass']) { - newTuningId = INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass'][0]; - } - - onUpdateSettings({ - ...settings, - instrument: newInstrumentId, - difficulty: defaultRange as Difficulty, - tuningId: newTuningId - }); - }; + // Note-filter panel: hidden by default (minimal UI), toggled here. + const [filtersOpen, setFiltersOpen] = useState(false); const updateRhythm = (updates: Partial) => { onUpdateSettings({ @@ -55,98 +30,36 @@ export const Controls: React.FC = ({ currentPitch }) => { }); }; - // Cast instrument to specific key if needed, or use generic record access - const availableTunings = INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] || []; - const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; + const pf = getPracticeFilter(settings); + const activeFilterCount = + (pf.keyEnabled ? 1 : 0) + + (pf.strings.length > 0 ? 1 : 0) + + (pf.fretWindowEnabled ? 1 : 0); - - - // 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) */} -
- {/* Row 1, Col 1 */} -
- - -
- - {/* Row 1, Col 2 (Conditional) */} - {currentInstrumentDef.showTuning ? ( -
- - -
- ) :
} {/* Spacer to maintain grid flow if needed, or omit to let items flow. User asked for specific rows. - If I omit, Note Set goes here. Let's omit for "Dense" feel, or keep empty div for strict rows? - The request "Top row: instrument and tuning" implies if tuning is missing, maybe just 1 item? - I'll output an empty div if I want to FORCE strict placement, but generally flow is better. - However, "Second row: note set..." suggests structure. - I will use an empty div if tuning is hidden to push Note Set to next row? - Actually, grid-template-columns: 1fr 1fr. - If Tuning is hidden, Note Set becomes Item 2. - I'll forgo complexity and just render what's available. */} - - {/* Row 2, Col 2 */} - {!inPhraseMode && ( -
- - -
- )} - + {/* Note filter: collapsed behind a button — visible only when needed */} +
+
- - {/* Target-note filter: unified controls, all modes */} - + {filtersOpen && } {/* Bottom Section: Tools Grid (2 Columns now) */}
diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 79f81e5..2d4ff56 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'; @@ -71,14 +71,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 +91,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 +124,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 +156,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 +167,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 +179,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 +192,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 +214,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, @@ -355,11 +338,11 @@ export const PhraseTrainer = forwardRef( 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) { @@ -579,11 +562,7 @@ 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' || phrase.material === 'scale' || phrase.material === 'arpeggio') && null} {phrase.material === 'melody' && ( <> @@ -799,11 +778,7 @@ export const PhraseTrainer = forwardRef( )} - {fretted && ( - - Fret window: use the "Fret window" controls below the sheet — shared across modes. - - )} + {fretted && null} {/* Pace lives in the metronome widget now: 'Sync to exercise' checked = tempo pace, @@ -816,11 +791,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. @@ -878,7 +848,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..79d1641 100644 --- a/src/components/TargetNoteControls.tsx +++ b/src/components/TargetNoteControls.tsx @@ -101,6 +101,35 @@ export const TargetNoteControls: React.FC = () => { )}
+ {/* Display key signature (sheet notation) */} +
+ + +
+ {/* Key filter */}
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..e13e347 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', From 1ec1353c0bf9e23f29f5fec0938438217bf7ce5f Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 15 Sep 2026 21:11:21 +0000 Subject: [PATCH 2/7] Add Assist mode: primitive note display for practicing from paper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New game mode 'assist' — no trainer, no scoring: - Large display of the currently mic-detected note - Live notation staff (same pipeline as the pitch monitor) - Live fretboard showing alternative positions for the played note (respects the fret-window practice filter) - Tuner and Metronome remain the shared footer tools, identical in all modes; user can practice own-pace from paper with a free metronome Header mode toggle gains 'Assist'; 'm' shortcut cycles back to sight reading from assist/phrase. Export PitchData type. --- src/App.css | 46 ++++++++++++++++++ src/App.tsx | 39 +++++++++++++-- src/components/AssistMode.tsx | 89 +++++++++++++++++++++++++++++++++++ src/hooks/usePitchDetector.ts | 2 +- src/types/SettingsTypes.ts | 2 +- 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 src/components/AssistMode.tsx diff --git a/src/App.css b/src/App.css index cc43cf1..6dbdcf0 100644 --- a/src/App.css +++ b/src/App.css @@ -1126,3 +1126,49 @@ 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; +} diff --git a/src/App.tsx b/src/App.tsx index ec5f151..4174bc7 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]; @@ -175,6 +177,25 @@ function App() { 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 })); @@ -207,16 +228,16 @@ function App() { 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 +284,13 @@ function App() { > Phrases +
- {filtersOpen && } + )} + {shouldShowFilters && } {/* Bottom Section: Tools Grid (2 Columns now) */}
diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 2d4ff56..a695d9e 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -28,6 +28,7 @@ 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'; @@ -435,118 +436,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 @@ -562,8 +453,6 @@ export const PhraseTrainer = forwardRef( - {(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && null} - {phrase.material === 'melody' && ( <> )} - {fretted && null} - {/* Pace lives in the metronome widget now: 'Sync to exercise' checked = tempo pace, unchecked = at-your-pace (step). */} @@ -815,9 +702,122 @@ export const PhraseTrainer = forwardRef( 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 && ( + <> + + + {(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 && ( + + )} +
+ + {/* Hint instruments */} {(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
diff --git a/src/components/TargetNoteControls.tsx b/src/components/TargetNoteControls.tsx index 79d1641..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 */} @@ -101,35 +106,6 @@ export const TargetNoteControls: React.FC = () => { )}
- {/* Display key signature (sheet notation) */} -
- - -
- {/* Key filter */}
@@ -137,15 +113,17 @@ export const TargetNoteControls: React.FC = () => { Key - + {!inPhraseMode && ( + + )}
- {pf.keyEnabled && ( + {(pf.keyEnabled || inPhraseMode) && ( <>
- + {!inPhraseMode && ( + + )} )}
From 97a9a83f68de581d721c2b0c9d49002b5b2a466a Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 15 Sep 2026 21:32:27 +0000 Subject: [PATCH 4/7] Fix Next/New no-op for seeded material + keyboard shortcuts after button clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nextAction: giuliani and single-chord arpeggios now roll a new seed too (they consume melodySeed); previously the handler silently did nothing for them. Scale drills keep no Next (deterministic). - New/Next button visibility matches the same condition, and the label is 'Next' for fixed material only. - Keyboard shortcuts: HTMLButtonElement removed from the interactive gate — after any mouse click the button keeps focus and previously swallowed N/R/P/H/L/V/M. Space/Enter on a focused button still activate it natively and no longer double-fire the global pauseToggle/skip handlers. --- src/App.tsx | 22 ++++++++++++++-------- src/components/PhraseTrainer.tsx | 18 +++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4174bc7..aeb5c20 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -126,7 +126,6 @@ function App() { const onInteractive = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || - target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || !!target?.isContentEditable; @@ -135,12 +134,17 @@ function App() { 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; + // Text inputs and selects must keep their native keys — shortcuts are + // for typing-free contexts. FOCUSED BUTTONS however stay eligible: + // after a click the button keeps focus, and blocking global shortcuts + // there made N/R/P/L silently dead until the user clicked elsewhere. + // Space/Enter on a focused button activate it natively and return below, + // so no double-fire. - // 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(); } @@ -171,7 +175,9 @@ 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; @@ -221,7 +227,7 @@ function App() { break; } - if (e.code === 'Space' || e.code === 'Enter') { + if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) { singleNoteRef.current?.skipNote(); } }; diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index a695d9e..f1b30b4 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -307,9 +307,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) { @@ -317,6 +315,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]); @@ -766,10 +770,10 @@ export const PhraseTrainer = forwardRef( Retry - {(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && ( + {(phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani' || fixedMaterial) && ( )} {phrase.pace === 'step' && ( @@ -787,10 +791,10 @@ export const PhraseTrainer = forwardRef( Retry - {(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && ( + {(phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani' || fixedMaterial) && ( )} From 6b52a049e883550bbb2fc0b916527f07cf0db599 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 15 Sep 2026 21:33:33 +0000 Subject: [PATCH 5/7] Restore text-entry gate for keyboard shortcuts (cleanup) --- src/App.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index aeb5c20..57e10eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -123,24 +123,24 @@ 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 HTMLSelectElement || !!target?.isContentEditable; + if (onTextEntry) return; if (e.code === 'Escape') { if (showHelp) setShowHelp(false); else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false })); return; } - // Text inputs and selects must keep their native keys — shortcuts are - // for typing-free contexts. FOCUSED BUTTONS however stay eligible: - // after a click the button keeps focus, and blocking global shortcuts - // there made N/R/P/L silently dead until the user clicked elsewhere. - // Space/Enter on a focused button activate it natively and return below, - // so no double-fire. - // 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). From 75ad4613bb7a1588edb6c760045ac4a9404de885 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 15 Sep 2026 22:27:04 +0000 Subject: [PATCH 6/7] Phrase mode: player-style transport, separate display toggles, preview cursor + stepping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Transport restructured like a music player: Start/Pause, step back/forward buttons (while previewing), Preview, Retry, Next. - Virtual instrument and Hint moved out of the transport row into a separate display-toggles row using switch-style toggles (same visual language as tuner/metronome switches) — pure show/hide, no run state. - Preview now tracks a cursor: the current preview event is marked in the notation (caret + color via existing currentIdx pipeline). - Preview stepping: pause-free stepping forward/backward one event at a time; each step replays from the cursor position so notes can be located on the fretboard. Arrow keys ←/→ work while previewing. - With Hint active, the fretboard/piano marks the preview cursor note (displayIdx follows the preview cursor). - previewIdx resets on structural changes and when the preview ends. --- src/App.css | 34 ++++++++++++ src/App.tsx | 7 +++ src/components/PhraseTrainer.tsx | 92 +++++++++++++++++++++++--------- src/hooks/usePhraseTrainer.ts | 69 ++++++++++++++++++------ 4 files changed, 162 insertions(+), 40 deletions(-) diff --git a/src/App.css b/src/App.css index 83c91eb..72a4f7f 100644 --- a/src/App.css +++ b/src/App.css @@ -1172,3 +1172,37 @@ button { 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 57e10eb..7d2b771 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -165,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; @@ -430,6 +436,7 @@ function App() { <>
Start / Pause / ResumeSpace
Preview phrase (Play / Stop)P
+
While previewing: step note forward / back→ / ←
Retry phraseR
New melody / next barsN
Skip note (at your pace)S
diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index f1b30b4..5d2eee1 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -35,11 +35,12 @@ 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, Square, 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; @@ -328,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(), @@ -337,8 +339,11 @@ 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.previewIdx : trainer.currentIdx; + const currentMidi = displayIdx >= 0 && events[displayIdx]?.pitch + ? events[displayIdx]!.pitch!.midi : null; const hintPositions = useMemo(() => { @@ -714,7 +719,7 @@ export const PhraseTrainer = forwardRef( {scoreOk ? ( ( {!settings.zenMode && ( <> + {trainer.phase === 'preview' && ( +
+ + +
+ )} - )} - {!settings.zenMode && ( - - )}
+ {/* 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 && ( diff --git a/src/hooks/usePhraseTrainer.ts b/src/hooks/usePhraseTrainer.ts index 9bc6da3..9326733 100644 --- a/src/hooks/usePhraseTrainer.ts +++ b/src/hooks/usePhraseTrainer.ts @@ -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,7 @@ export function usePhraseTrainer( const matcherRef = useRef(null); const repeatBlockedRef = useRef(false); + const previewIdxRef = useRef(-1); const phaseRef = useRef('ready'); const paceRef = useRef(config.pace); const configRef = useRef(config); @@ -157,6 +164,8 @@ export function usePhraseTrainer( setAccumulated({ total: 0, matched: 0, missed: 0, skipped: 0 }); audioEngine.cancelGroup(PREVIEW_GROUP); audioEngine.cancelGroup(CLICK_GROUP); + previewIdxRef.current = -1; + setPreviewIdx(-1); setPhaseBoth('ready'); setCurrentIdx(-1); setCountInLeft(0); @@ -485,25 +494,26 @@ 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; + const stopPreview = useCallback(() => { + audioEngine.cancelGroup(PREVIEW_GROUP); + if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; } + previewIdxRef.current = -1; + setPreviewIdx(-1); + setPhaseBoth('ready'); + }, [setPhaseBoth]); + + 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; 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). @@ -511,20 +521,47 @@ export function usePhraseTrainer( // 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; + const totalSec = ((events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0) - startTick) / PPQ) * spb; + if (previewTimerRef.current) clearTimeout(previewTimerRef.current); previewTimerRef.current = setTimeout(() => { if (runTokenRef.current === token) { setPhaseBoth('ready'); + setPreviewIdx(-1); } }, 150 + totalSec * 1000 + 200); }); - }, [events, pause, setPhaseBoth]); + }, [events, setPhaseBoth]); + + const previewToggle = useCallback(() => { + if (phaseRef.current === 'preview') { + stopPreview(); + return; + } + pause(); + runTokenRef.current++; + const token = runTokenRef.current; + setPreviewIdx(0); + previewIdxRef.current = 0; + playPreviewFrom(0, token); + }, [pause, playPreviewFrom, stopPreview]); + + /** Player-style stepping while previewing: move the cursor, play that + * single note so the user can find it on the instrument. */ + const previewStep = useCallback((dir: 1 | -1) => { + if (phaseRef.current !== 'preview') return; + const nextIdx = Math.max(0, Math.min(events.length - 1, previewIdxRef.current + dir)); + if (nextIdx === previewIdxRef.current) return; + previewIdxRef.current = nextIdx; + setPreviewIdx(nextIdx); + const token = ++runTokenRef.current; + playPreviewFrom(nextIdx, token); + }, [events.length, playPreviewFrom]); const virtualTap = useCallback((midi: number) => { const p = phaseRef.current; @@ -602,6 +639,7 @@ export function usePhraseTrainer( statuses, currentIdx, countInLeft, + previewIdx, scoreStart: scoreStartRef.current, spb: spbRef.current, error, @@ -611,6 +649,7 @@ export function usePhraseTrainer( retry, skip, previewToggle, + previewStep, virtualTap, pause, stop, From 672cb341a785ffca2935e778b1e4b49febd377f7 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 15 Sep 2026 22:37:29 +0000 Subject: [PATCH 7/7] Preview: pause/resume with cursor, stepping only while paused, indicator follows playback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New phase 'previewPaused': pause stops scheduled audio but keeps the preview cursor; resume continues from the cursor position. - Step buttons (and arrow keys) are active ONLY while the preview is paused; each step moves the cursor one event and plays that single note as a find-it-on-the-neck cue. - Preview indicator now follows playback in real time: a cursor interval advances previewIdx by audio time (event start ticks), so the notation caret moves with the sounding note — also while paused at the stepped position. - Main Start button shares the semantics: while previewing it shows Pause/Resume and routes to the preview controls (exercise and preview transport behave consistently). - External pause (settings dialog, tab hide) during preview now keeps the cursor (previewPaused) instead of dropping to ready. --- src/components/PhraseTrainer.tsx | 29 +++++--- src/hooks/usePhraseTrainer.ts | 114 +++++++++++++++++++++++-------- 2 files changed, 104 insertions(+), 39 deletions(-) diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 5d2eee1..b3c958f 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -35,7 +35,7 @@ import { TUNINGS, getFretboardPositions } from '../music/Tunings'; import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs'; const AUTO_CONTINUE_DELAY_MS = 1500; -import { Play, Pause, RotateCcw, SkipForward, SkipBack, 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; @@ -341,7 +341,9 @@ export const PhraseTrainer = forwardRef( const events = scoreEvents(activeScore); // 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.previewIdx : trainer.currentIdx; + const displayIdx = trainer.phase === 'preview' || trainer.phase === 'previewPaused' + ? trainer.previewIdx + : trainer.currentIdx; const currentMidi = displayIdx >= 0 && events[displayIdx]?.pitch ? events[displayIdx]!.pitch!.midi : null; @@ -373,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') { @@ -392,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]); @@ -719,7 +723,7 @@ export const PhraseTrainer = forwardRef( {scoreOk ? ( ( {!settings.zenMode && ( <> - {trainer.phase === 'preview' && ( + {trainer.phase === 'previewPaused' && (
)}