From ffb94cc93ffaf6d1756f67d0f7194a3201d93b1e Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Mon, 14 Sep 2026 19:46:23 +0000 Subject: [PATCH] Metronome rewrite: single sound checkbox, clean click architecture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MetronomeWidget rebuilt cleanly: ONE 'Click sound' checkbox (the earlier restructure left duplicated option rows behind — neither worked). Widget body always visible (dimmed when disarmed); toggle arms sound+pendulum. - Click decision unified: 'sync to metronome' derives phrase clickSound from the widget's sound checkbox (no separate phrase click option); 'at your pace' clicks when the metronome is armed. - Chord selection: abwählbare Liste (arpeggioChordSelection) restricts progression chords; first bar draws a random chord FROM THE SELECTION (fixed first chord repeated the exercise identically). - Scales: rhythm setting (quarters/eighths) shared with arpeggios; drill id includes rhythm to invalidate cached scores. - Beam fix: register Beam via note.setBeam so VexFlow suppresses flags (eighths/16ths previously drew BOTH beam and flag). --- src/components/MetronomeWidget.tsx | 102 +++++++++++----------------- src/components/PhraseSheetMusic.tsx | 5 ++ src/components/PhraseTrainer.tsx | 30 +++----- src/music/arpeggioGenerator.ts | 28 ++++++-- src/music/scaleDrills.ts | 18 +++-- src/types/SettingsTypes.ts | 7 +- 6 files changed, 93 insertions(+), 97 deletions(-) diff --git a/src/components/MetronomeWidget.tsx b/src/components/MetronomeWidget.tsx index cdb7029..cb63485 100644 --- a/src/components/MetronomeWidget.tsx +++ b/src/components/MetronomeWidget.tsx @@ -1,10 +1,15 @@ /** - * Metronome widget with a linear pendulum indicator. + * Metronome widget — always-visible tempo controls + pendulum. * - * The pendulum is a CSS-animated dot whose half-period equals one beat: - * it travels left→right on even beats and right→left on odd beats, driven - * by the metronome's onTick callback (no drift — synced to the audible - * click, not to wall-clock CSS timing alone). + * One toggle ("On") arms sound + animation. One `sound` checkbox decides + * whether an armed metronome clicks (the pendulum always swings when armed, + * giving silent visual tempo). There is exactly ONE sound checkbox here. + * + * Click sources (no doubling possible): + * - Ungated (single-note modes, phrase "at your pace"): this widget's own + * scheduler clicks when armed && sound. + * - Gated (phrase "sync to metronome"): the phrase run's scheduler owns the + * clock; this widget is silent and its pendulum mirrors phrase beats. */ import { useEffect, useRef, useState, useCallback } from 'react'; import { useMetronome } from '../hooks/useMetronome'; @@ -19,11 +24,10 @@ interface MetronomeWidgetProps { onUpdate: (updates: Partial) => void; /** Single-note mode extra: auto-advance targets on each tick. */ showAutoAdvance?: boolean; - onBeat?: () => void; /** Compact layout (phrase transport row). */ compact?: boolean; - /** External gate (Phrase Mode): the metronome only ticks while the - * phrase run is active — user toggle arms it, the gate fires it. */ + /** External gate (Phrase Mode "sync to metronome"): the widget is muted + * and the pendulum mirrors the run's beats. null = free-running. */ gate?: boolean | null; } @@ -31,49 +35,44 @@ export const MetronomeWidget: React.FC = ({ rhythm, onUpdate, showAutoAdvance = false, - onBeat, compact = false, gate = null, }) => { - // Gated mode (Phrase Mode): the widget does NOT run its own scheduler — - // the phrase run drives both the audible click and this pendulum via - // phraseBeatBus, so visual and click are always the same clock. - // Non-gated: widget ticks itself (single metronome instance below). 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; + if (now - lastTickRef.current < 60) return; // glitch guard lastTickRef.current = now; setBeatParity(beat % 2); - onBeatRef.current?.(); }, []); - // Gated: mirror phrase-run beats onto the pendulum at their audio times. + const gated = gate !== null; + const running = rhythm.active && (gate === null || gate); + + // Own click scheduler — only when NOT gated. + const { restart } = useMetronome({ + bpm: rhythm.bpm, + volume: rhythm.sound ? rhythm.volume : 0, + playing: running && !gated, + onTick: handleTick, + }); + void restart; + + // Gated: pendulum mirrors the phrase run's beats (audio-time scheduled). useEffect(() => { - if (gate === null || !gate) return; + if (!gated || !running) return; return phraseBeatBus.subscribe((_beat, at) => { const delay = Math.max(0, (at - audioEngine.now()) * 1000); window.setTimeout(() => setBeatParity(p => (p + 1) % 2), delay); }); - }, [gate]); - - const { restart } = useMetronome({ - bpm: rhythm.bpm, - volume: rhythm.sound && gate === null ? rhythm.volume : 0, - playing: rhythm.active && (gate === null || gate), - onTick: handleTick, - }); - void restart; + }, [gated, running]); const beatMs = 60000 / Math.max(1, rhythm.bpm); return (
+ {/* Header: label + single arm toggle */}
- {/* Always visible: pendulum + BPM; the toggle gates sound + animation */} -
- {/* Linear pendulum — same element, left transitions each beat */} + {/* Always visible body; dimmed when disarmed */} +
+ {/* Pendulum */}
= ({ />
+ {/* BPM / Timer controls */}
{!compact && (
@@ -137,9 +137,7 @@ export const MetronomeWidget: React.FC = ({ className="control-button small" onClick={() => onUpdate({ bpm: Math.max(30, rhythm.bpm - 1) })} style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }} - > - − - + >− = ({ className="control-button small" onClick={() => onUpdate({ bpm: Math.min(300, rhythm.bpm + 1) })} style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }} - > - + - + >+ = ({
)} -
+ {/* Options row */} +
{showAutoAdvance ? (
- -
- {showAutoAdvance ? ( - - ) : } - -
); }; diff --git a/src/components/PhraseSheetMusic.tsx b/src/components/PhraseSheetMusic.tsx index c85ab60..994edd6 100644 --- a/src/components/PhraseSheetMusic.tsx +++ b/src/components/PhraseSheetMusic.tsx @@ -338,6 +338,9 @@ export const PhraseSheetMusic: React.FC = ({ } // --- Beams: consecutive eighth/16th pairs within one beat --- + // Note: the Beam must be registered on each note via setBeam, + // otherwise VexFlow still draws flags (beam === undefined in + // shouldDrawFlag) and you get BOTH flag and beam. const beamed = frags.filter(f => (f.isEighth || f.isSixteenth) && f.note instanceof StaveNote); for (let i = 0; i + 1 < beamed.length; i++) { const a = beamed[i], b2 = beamed[i + 1]; @@ -350,6 +353,8 @@ export const PhraseSheetMusic: React.FC = ({ const beatB = Math.floor((b2.start - measureStart) / 480); if (beatA === beatB) { const beam = new Beam([a.note, b2.note], false); + a.note.setBeam(beam); + b2.note.setBeam(beam); beam.setContext(context).draw(); } } diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 538f76c..ec004c3 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -82,13 +82,14 @@ export const PhraseTrainer = forwardRef( // (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; - // "Sync to metronome": the phrase run IS the click source while it - // plays (scheduler-driven, with count-in) — the metronome widget only - // mirrors the beats visually and is muted in Phrase Mode, so clicks - // can never double. The separate-click checkbox stays as an explicit - // opt-in for a click even in "at your pace" mode. - const metronomeActive = settings.rhythm.active; - phrase.clickSound = phrase.pace === 'tempo' ? true : phrase.clickSound && metronomeActive; + // "Sync to metronome" (tempo): the phrase run owns the clock and the + // click decision comes from the METRONOME widget's sound checkbox — + // one place, no separate phrase click option. "At your pace" (step): + // the widget clicks freely when armed (metronomeActive); a legacy + // phrase.clickSound=true also allows clicks without the metronome. + phrase.clickSound = phrase.pace === 'tempo' + ? settings.rhythm.sound + : settings.rhythm.active || phrase.clickSound; const setPhrase = useCallback((updates: Partial) => { updateSettings(s => { const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } }; @@ -183,6 +184,7 @@ export const PhraseTrainer = forwardRef( direction: phrase.scaleDirection, coverage: phrase.scaleCoverage, meter: { numerator: phrase.meterNumerator, denominator: 4 }, + rhythm: phrase.scaleRhythm, }, pool); } if (phrase.material === 'arpeggio') { @@ -210,7 +212,7 @@ export const PhraseTrainer = forwardRef( } return null; }, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator, - phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.libraryId, + phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId, phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage, phrase.arpeggioBars, phrase.arpeggioProgression, phrase.arpeggioRhythm, melodySeed, melodySeed, pool, importedScore]); @@ -756,19 +758,9 @@ export const PhraseTrainer = forwardRef( {phrase.pace === 'tempo' && ( - Clicks come from the run (with count-in); the metronome widget mirrors the beats. + Clicks: use the metronome's "Click sound" checkbox (with count-in, synced). )} - {phrase.pace === 'step' && ( - - )}