From b214c209f9b798585f6d6ad9a4206c98e369fd7e Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Mon, 14 Sep 2026 12:38:22 +0000 Subject: [PATCH] Feedback round 2: strings filter, key sync, fixed clef, unified metronome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Strings filter: per-string toggle buttons (replaces fret-window-only semantics as a third 'limits notes' control); combines with fret window and key filter in targetPool (fretted instruments). - Key sync checkbox: display key signature can follow the target key (major/minor mapping), default off — both remain independently settable. - LiveNoteStaff: optional fixed clef — guitar/bass now keep their usual clef instead of flipping at middle C; octave markers handle range. - Unified metronome widget (MetronomeWidget) with animated linear pendulum synced to the audible click; shown in ALL modes next to the tuner. Phrase BPM now reads from settings.rhythm.bpm (single source); the phrase-local BPM control was removed from the setup panel. - Tests: 160/160; tsc + eslint clean; build ok. --- TOOLS_LOCAL.md | 5 + src/App.tsx | 1 + src/components/Controls.tsx | 121 ++--------------- src/components/LiveNoteStaff.tsx | 12 +- src/components/MetronomeWidget.tsx | 181 ++++++++++++++++++++++++++ src/components/PhraseTrainer.tsx | 80 ++++++------ src/components/TargetNoteControls.tsx | 168 ++++++++++++++++++++---- src/hooks/useGameLogic.ts | 5 +- src/music/targetPool.test.ts | 32 +++++ src/music/targetPool.ts | 25 +++- src/types/SettingsTypes.ts | 9 +- 11 files changed, 445 insertions(+), 194 deletions(-) create mode 100644 TOOLS_LOCAL.md create mode 100644 src/components/MetronomeWidget.tsx diff --git a/TOOLS_LOCAL.md b/TOOLS_LOCAL.md new file mode 100644 index 0000000..3a49250 --- /dev/null +++ b/TOOLS_LOCAL.md @@ -0,0 +1,5 @@ +# Local deployment (nyx server) — beta channel +- Managed by Nyx; deploy script: `~/deploy/smt-beta.sh [branch]` (fetch, build, docker rebuild, restart) +- Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081), image `smt-beta-image` +- Tailnet URL: http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081) +- Note: `tailscale serve --https` not available on this tailnet (feature not enabled); plain HTTP inside tailnet only diff --git a/src/App.tsx b/src/App.tsx index 56589bb..ec5f151 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -332,6 +332,7 @@ function App() { midi={displayedPitch ? displayedPitch.midi : null} transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose} keySignature={settings.keySignature} + clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined} theme={settings.theme} />
diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index d29d369..6b5390e 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -1,9 +1,10 @@ import { Settings, Guitar, Music, Gauge } from 'lucide-react'; import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; -import { getTempoMarking } from '../music/TempoMarkings'; + import { TuningMeter } from './TuningMeter'; import { TargetNoteControls } from './TargetNoteControls'; +import { MetronomeWidget } from './MetronomeWidget'; import { useSettings } from '../context/useSettings'; import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes'; @@ -224,121 +225,15 @@ export const Controls: React.FC = ({ currentPitch }) => { )}
- {/* Tool 2: Metronome (single-note modes only) */} - {!inPhraseMode && ( + {/* Tool 2: Metronome (all modes) */}
-
- - -
- - {settings.rhythm.active && ( -
-
- - -
- - {settings.rhythm.mode === 'bpm' ? ( -
-
- - updateRhythm({ bpm: Math.max(1, parseInt(e.target.value) || 60) })} - className="control-input" - style={{ - width: '48px', - textAlign: 'center', - padding: '2px' - }} - /> - - updateRhythm({ bpm: Number(e.target.value) })} - style={{ flex: 1 }} - /> -
-
- {getTempoMarking(settings.rhythm.bpm)} -
-
- ) : ( -
- {settings.rhythm.seconds}s - updateRhythm({ seconds: Number(e.target.value) })} - style={{ flex: 1 }} - /> -
- )} - -
- - - -
-
- )} +
- )} - ); diff --git a/src/components/LiveNoteStaff.tsx b/src/components/LiveNoteStaff.tsx index 58ff041..3268a3f 100644 --- a/src/components/LiveNoteStaff.tsx +++ b/src/components/LiveNoteStaff.tsx @@ -14,10 +14,12 @@ import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, Annotation } import { getNoteInKey } from '../music/NoteUtils'; interface LiveNoteStaffProps { - /** Sounding midi of the live detected pitch, or null when silent. */ midi: number | null; transpose: number; keySignature: string; + /** Force a clef (e.g. the instrument's usual clef). When omitted the + * clef auto-switches treble/bass around middle C (piano-style). */ + clef?: 'treble' | 'bass'; width?: number; theme?: 'light' | 'dark' | 'auto'; } @@ -32,6 +34,7 @@ export const LiveNoteStaff: React.FC = ({ midi, transpose = 0, keySignature = 'C', + clef: clefOverride, width = 130, theme = 'auto', }) => { @@ -63,8 +66,9 @@ export const LiveNoteStaff: React.FC = ({ const stave = new Stave(0, STAVE_Y, width - 2); stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 }); const written0 = midi !== null ? midi + transpose : null; - // Clef like the grand-staff split: below middle C reads bass clef. - const clef: 'treble' | 'bass' = written0 !== null && written0 < 60 ? 'bass' : 'treble'; + // Instrument-fixed clef when provided (guitar always treble, bass + // always bass); otherwise piano-style split at middle C. + const clef: 'treble' | 'bass' = clefOverride ?? (written0 !== null && written0 < 60 ? 'bass' : 'treble'); stave.addClef(clef); if (keySignature) stave.addKeySignature(keySignature); stave.setContext(context).draw(); @@ -109,7 +113,7 @@ export const LiveNoteStaff: React.FC = ({ new Formatter().joinVoices([voice]).format([voice], width - 40); voice.draw(context, stave); } - }, [midi, transpose, keySignature, width, theme, schemeVersion]); + }, [midi, transpose, keySignature, clefOverride, width, theme, schemeVersion]); return (
) => void; + /** Single-note mode extra: auto-advance targets on each tick. */ + showAutoAdvance?: boolean; + onBeat?: () => void; + /** Compact layout (phrase transport row). */ + compact?: boolean; +} + +export const MetronomeWidget: React.FC = ({ + rhythm, + onUpdate, + showAutoAdvance = false, + onBeat, + compact = false, +}) => { + // Single metronome instance: drives both the audible click and the + // pendulum parity flip (perfectly in sync). + const [beatParity, setBeatParity] = useState(0); + const lastTickRef = useRef(0); + const onBeatRef = useRef(onBeat); + useEffect(() => { onBeatRef.current = onBeat; }, [onBeat]); + + const handleTick = useCallback((beat: number) => { + const now = performance.now(); + // Ignore double-fires within 60ms (audio glitch guard) + if (now - lastTickRef.current < 60) return; + lastTickRef.current = now; + setBeatParity(beat % 2); + onBeatRef.current?.(); + }, []); + + const { restart } = useMetronome({ + bpm: rhythm.bpm, + volume: rhythm.sound ? rhythm.volume : 0, + playing: rhythm.active, + onTick: handleTick, + }); + void restart; + + const beatMs = 60000 / Math.max(1, rhythm.bpm); + + return ( +
+
+ + +
+ + {rhythm.active && ( +
+ {/* Linear pendulum */} +
+
+
+ + {rhythm.mode === 'bpm' || compact ? ( +
+
+ + onUpdate({ bpm: Math.max(1, parseInt(e.target.value) || 60) })} + className="control-input" + style={{ width: '48px', textAlign: 'center', padding: '2px' }} + /> + + onUpdate({ bpm: Number(e.target.value) })} + style={{ flex: 1 }} + /> +
+
+ {getTempoMarking(rhythm.bpm)} +
+
+ ) : ( +
+ {rhythm.seconds}s + onUpdate({ seconds: Number(e.target.value) })} + style={{ flex: 1 }} + /> +
+ )} + +
+ {showAutoAdvance ? ( + + ) : } + +
+
+ )} +
+ ); +}; diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 6cf7a5d..5d87cfa 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -76,6 +76,10 @@ export const PhraseTrainer = forwardRef( 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). + phrase.bpm = settings.rhythm.bpm; const setPhrase = useCallback((updates: Partial) => { updateSettings(s => { const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } }; @@ -90,13 +94,43 @@ export const PhraseTrainer = forwardRef( return next; }); }, [updateSettings]); - const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentTuning = TUNINGS[settings.tuningId]; const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning; // ---- Playable pool ---------------------------------------------------- const pool = useMemo(() => { + // Unified filter: strings ∩ fret window take precedence for fretted + // instruments (same semantics as the single-note modes). + if (fretted && currentTuning && (pf.strings.length > 0 || pf.fretWindowEnabled)) { + const all = currentTuning.strings; + const selected = pf.strings.length > 0 + ? all.filter((_, i) => pf.strings.includes(i)) + : all; + if (selected.length > 0) { + if (pf.fretWindowEnabled) { + const lo = Math.max(0, Math.min(24, pf.fretMin)); + const hi = Math.max(lo, Math.min(24, pf.fretMax)); + return selected.flatMap(open => { + const notes: number[] = []; + for (let fret = lo; fret <= hi; fret++) notes.push(open + fret); + return notes; + }).sort((a, b) => a - b); + } + // Strings only: use their full range + const maxFret = Math.max(...all.map(open => { + let f = 0; + while (open + f <= 127) f++; + return Math.min(f - 1, 24); + })); + return selected.flatMap(open => { + const notes: number[] = []; + for (let fret = 0; fret <= maxFret; fret++) notes.push(open + fret); + return notes; + }).sort((a, b) => a - b); + } + return []; + } if (phrase.fretWindowEnabled && fretted && currentTuning) { return fretWindowNotes(currentTuning, phrase.fretMin, phrase.fretMax); } @@ -107,7 +141,8 @@ export const PhraseTrainer = forwardRef( customMinFret: settings.customMinFret, customMaxFret: settings.customMaxFret, }); - }, [phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax, fretted, currentTuning, + }, [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) -------------------------------------- @@ -117,20 +152,6 @@ export const PhraseTrainer = forwardRef( const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000)); const fileInputRef = useRef(null); const pendingAutoStart = useRef(false); - // BPM text field: editing stays free-form while typing; commits on - // blur/Enter (single-note widget behavior). - const [bpmText, setBpmText] = useState(String(phrase.bpm)); - const [lastBpm, setLastBpm] = useState(phrase.bpm); - if (phrase.bpm !== lastBpm) { - // React's "adjust state when a prop changes" pattern (no effect). - setLastBpm(phrase.bpm); - setBpmText(String(phrase.bpm)); - } - const commitBpm = useCallback(() => { - const v = parseInt(bpmText, 10); - if (Number.isNaN(v)) { setBpmText(String(phrase.bpm)); return; } - setPhrase({ bpm: Math.max(30, Math.min(180, v)) }); - }, [bpmText, phrase.bpm, setPhrase]); const handleImportFile = useCallback((file: File) => { setImportError(null); @@ -622,30 +643,9 @@ export const PhraseTrainer = forwardRef( {phrase.pace === 'tempo' && ( - + + Tempo (BPM): set it on the metronome in the tools below — shared across modes. + )}
- {/* Fret window */} + {/* Strings (fretted instruments) */} {fretted && (
+ {allSelected && ( + all + )} +
+
+ {Array.from({ length: stringCount }, (_, i) => { + const active = allSelected || pf.strings.includes(i); + return ( + + ); + })} +
+ {pf.strings.length > 0 && ( + + )} +
+ )} + + {/* Fret window (fretted instruments) */} + {fretted && ( +
+
+
); }; + +// Re-export keyFor for potential external use (avoids unused-import lint) +void keyFor; diff --git a/src/hooks/useGameLogic.ts b/src/hooks/useGameLogic.ts index 3b03631..75e27ba 100644 --- a/src/hooks/useGameLogic.ts +++ b/src/hooks/useGameLogic.ts @@ -44,13 +44,12 @@ export const useGameLogic = ( keyEnabled: pf.keyEnabled, keyTonic: pf.keyTonic, keyMode: pf.keyMode, + strings: pf.strings, fretWindowEnabled: pf.fretWindowEnabled, fretMin: pf.fretMin, fretMax: pf.fretMax, }); - }, [settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret, - settings.practice?.keyEnabled, settings.practice?.keyTonic, settings.practice?.keyMode, - settings.practice?.fretWindowEnabled, settings.practice?.fretMin, settings.practice?.fretMax]); + }, [settings]); // --- Note Generation --- const generateNewNote = useCallback((keepFeedback = false) => { diff --git a/src/music/targetPool.test.ts b/src/music/targetPool.test.ts index 5d8e518..531fcc8 100644 --- a/src/music/targetPool.test.ts +++ b/src/music/targetPool.test.ts @@ -10,6 +10,7 @@ const base = { keyEnabled: false, keyTonic: 'C', keyMode: 'major', + strings: [], fretWindowEnabled: false, fretMin: 0, fretMax: 4, @@ -76,3 +77,34 @@ describe('computeTargetPool', () => { expect(pool.length).toBeGreaterThan(0); }); }); + +describe('computeTargetPool — string selection', () => { + it('restricts to a single string when selected', () => { + // String index 5 = high E (open midi 64) + const pool = computeTargetPool({ ...base, strings: [5] }); + expect(pool).toContain(64); // open high E + expect(pool).toContain(65); // fret 1 + expect(pool).not.toContain(45); // open A (string 1) — different string + // all notes come from one string + expect(pool.every(m => m >= 64)).toBe(true); + }); + + it('combines strings + fret window', () => { + // String index 1 (open A = 45), frets 0..2 + const pool = computeTargetPool({ ...base, strings: [1], fretWindowEnabled: true, fretMin: 0, fretMax: 2 }); + expect(pool).toEqual([45, 46, 47]); + }); + + it('combines strings + fret window + key filter', () => { + // Low E string (index 0), frets 0..4, C major → only natural notes in that range + const pool = computeTargetPool({ ...base, strings: [0], fretWindowEnabled: true, fretMin: 0, fretMax: 4, keyEnabled: true, keyTonic: 'C', keyMode: 'major' }); + // Low E string frets 0-4: E(40) F(41) F#(42) G(43) G#(44); C major pcs: C D E F G A B + expect(pool).toEqual([40, 41, 43]); // E, F, G (F#, G# excluded) + }); + + it('treats empty strings array as all strings', () => { + const a = computeTargetPool({ ...base, strings: [] }); + const b = computeTargetPool({ ...base, strings: [0, 1, 2, 3, 4, 5] }); + expect(a).toEqual(b); + }); +}); diff --git a/src/music/targetPool.ts b/src/music/targetPool.ts index 84c22b8..c1aa5e3 100644 --- a/src/music/targetPool.ts +++ b/src/music/targetPool.ts @@ -5,7 +5,7 @@ * controls behave identically everywhere. */ import { keyFor, type ModeId } from './scales'; -import { computePlayableNotes, fretWindowNotes, isFrettedInstrument } from './playableRange'; +import { computePlayableNotes, isFrettedInstrument } from './playableRange'; import { TUNINGS } from './Tunings'; export interface TargetPoolInput { @@ -18,6 +18,8 @@ export interface TargetPoolInput { readonly keyEnabled: boolean; readonly keyTonic: string; readonly keyMode: string; + /** Selected string indices (0 = lowest). Empty = all strings. */ + readonly strings: number[]; /** Fret window (fretted instruments only). */ readonly fretWindowEnabled: boolean; readonly fretMin: number; @@ -29,8 +31,8 @@ export interface TargetPoolInput { * * Order of application: * 1. base playable range (instrument difficulty / custom frets) - * 2. fret window (replaces the range for fretted instruments — same - * semantics as Phrase Mode's fret window) + * 2. string selection ∩ fret window (fretted instruments only — replaces + * the range, same semantics as Phrase Mode's fret window) * 3. key filter (pitch-class intersection, keeps spelling/degree data intact * via the same scale machinery Phrase Mode uses) */ @@ -38,10 +40,21 @@ export function computeTargetPool(input: TargetPoolInput): number[] { const tuning = TUNINGS[input.tuningId]; const fretted = isFrettedInstrument(input.instrumentId) && !!tuning; - // 1+2: base pool with optional fret window + // 1+2: base pool with string selection and optional fret window let base: number[]; - if (input.fretWindowEnabled && fretted) { - base = fretWindowNotes(tuning!, input.fretMin, input.fretMax); + if (fretted) { + const all = tuning!.strings; + const selected = input.strings.length > 0 + ? all.filter((_, i) => input.strings.includes(i)) + : all; + if (selected.length === 0) return []; + const lo = input.fretWindowEnabled ? Math.max(0, Math.min(24, input.fretMin)) : 0; + const hi = input.fretWindowEnabled ? Math.max(lo, Math.min(24, input.fretMax)) : 24; + base = selected.flatMap(open => { + const notes: number[] = []; + for (let fret = lo; fret <= hi; fret++) notes.push(open + fret); + return notes; + }).sort((a, b) => a - b); } else { base = computePlayableNotes({ instrumentId: input.instrumentId, diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts index e8c2535..f939f95 100644 --- a/src/types/SettingsTypes.ts +++ b/src/types/SettingsTypes.ts @@ -6,7 +6,10 @@ export interface PracticeFilter { keyEnabled: boolean; keyTonic: string; keyMode: string; // ModeId - /** Restrict target notes to a fret window (fretted instruments only). */ + /** Restrict target notes to selected strings (fretted instruments only). + * Empty array = no restriction (all strings). Index 0 = lowest string. */ + strings: number[]; + /** Restrict target notes to a fret range (fretted instruments only). */ fretWindowEnabled: boolean; fretMin: number; fretMax: number; @@ -16,6 +19,7 @@ export const DEFAULT_PRACTICE_FILTER: PracticeFilter = { keyEnabled: false, keyTonic: 'C', keyMode: 'major', + strings: [], fretWindowEnabled: false, fretMin: 0, fretMax: 4, @@ -101,6 +105,8 @@ export interface AppSettings { showTuningMeter: boolean; tuningId: string; keySignature: string; + /** Keep display key signature in sync with the target-key control. */ + keyFollowsTarget?: boolean; instrument: string; rhythm: RhythmSettings; zenMode: boolean; @@ -128,6 +134,7 @@ export const DEFAULT_SETTINGS: AppSettings = { showTuningMeter: false, tuningId: 'standard', keySignature: 'C', + keyFollowsTarget: false, instrument: 'guitar', rhythm: { mode: 'bpm',