diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index 7c851d5..f11b553 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -181,6 +181,7 @@ export const Controls: React.FC = ({ currentPitch }) => { rhythm={settings.rhythm} onUpdate={updateRhythm} showAutoAdvance={!inPhraseMode} + syncMode={inPhraseMode ? 'option' : null} gate={inPhraseMode ? phraseRunActive : null} /> diff --git a/src/components/MetronomeWidget.tsx b/src/components/MetronomeWidget.tsx index cb63485..a0d728c 100644 --- a/src/components/MetronomeWidget.tsx +++ b/src/components/MetronomeWidget.tsx @@ -1,15 +1,16 @@ /** * Metronome widget — always-visible tempo controls + pendulum. * - * 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. + * Modes: + * - Plain (single-note modes): one on/off toggle arms sound + animation. + * - Sync option (Phrase Mode): the toggle switches between + * * FREE — the metronome runs independently (own clock, starts as soon + * as toggled, with or without clicks per the sound checkbox) + * * SYNC — the metronome starts with the exercise (count-in included), + * is silent while idle, and its pendulum mirrors the run's beats. * - * 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. + * Exactly ONE sound checkbox exists (here). In sync mode it governs the + * run's click scheduler too — clicks can never double. */ import { useEffect, useRef, useState, useCallback } from 'react'; import { useMetronome } from '../hooks/useMetronome'; @@ -20,14 +21,15 @@ import type { RhythmSettings } from '../types/SettingsTypes'; interface MetronomeWidgetProps { rhythm: RhythmSettings; - /** Persist changes (bpm, active, sound). Auto-advance is single-note only. */ + /** Persist changes. */ onUpdate: (updates: Partial) => void; /** Single-note mode extra: auto-advance targets on each tick. */ showAutoAdvance?: boolean; /** Compact layout (phrase transport row). */ compact?: boolean; - /** External gate (Phrase Mode "sync to metronome"): the widget is muted - * and the pendulum mirrors the run's beats. null = free-running. */ + /** 'option': show the sync/free toggle (Phrase Mode). null: plain on/off. */ + syncMode?: 'option' | null; + /** External gate (sync mode): true while the exercise runs. */ gate?: boolean | null; } @@ -36,6 +38,7 @@ export const MetronomeWidget: React.FC = ({ onUpdate, showAutoAdvance = false, compact = false, + syncMode = null, gate = null, }) => { const [beatParity, setBeatParity] = useState(0); @@ -47,47 +50,66 @@ export const MetronomeWidget: React.FC = ({ setBeatParity(beat % 2); }, []); - const gated = gate !== null; - const running = rhythm.active && (gate === null || gate); + const isSync = syncMode === 'option' && !!rhythm.syncToExercise; + const runActive = gate === true; + // Sync: pendulum/click only while the run is active (the run's scheduler + // is the click source; the widget's own scheduler stays off). + // Free/plain: the widget's own scheduler runs whenever armed. + const running = isSync ? runActive : rhythm.active; - // Own click scheduler — only when NOT gated. const { restart } = useMetronome({ bpm: rhythm.bpm, volume: rhythm.sound ? rhythm.volume : 0, - playing: running && !gated, + playing: running && !isSync, onTick: handleTick, }); void restart; - // Gated: pendulum mirrors the phrase run's beats (audio-time scheduled). + // Sync: pendulum mirrors the phrase run's beats (audio-time scheduled). useEffect(() => { - if (!gated || !running) return; + if (!isSync || !runActive) return; return phraseBeatBus.subscribe((_beat, at) => { const delay = Math.max(0, (at - audioEngine.now()) * 1000); window.setTimeout(() => setBeatParity(p => (p + 1) % 2), delay); }); - }, [gated, running]); + }, [isSync, runActive]); const beatMs = 60000 / Math.max(1, rhythm.bpm); + const armed = rhythm.active; return (
- {/* Header: label + single arm toggle */} + {/* Header: label + mode toggle */}
- + {syncMode === 'option' ? ( + + ) : ( + + )}
{/* Always visible body; dimmed when disarmed */} -
+
{/* Pendulum */}
= ({ background: 'var(--color-text-main)', transform: 'translateY(-50%)', transition: `left ${beatMs}ms linear`, - display: rhythm.active ? 'block' : 'none', + display: armed && running ? 'block' : 'none', }} />
diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index ec004c3..f66b5fd 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -82,14 +82,16 @@ 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" (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. + // Click decision — ONE source: the metronome widget's sound checkbox. + // - Tempo + syncToExercise: the run clicks when armed && sound + // (silent while idle; starts with the exercise incl. count-in). + // - Tempo + free metronome: the widget clicks freely; the run stays + // silent (its beats are visual only) to avoid doubling. + // - Step: widget clicks freely; phrase clickSound follows the switch. + const sync = settings.rhythm.syncToExercise; phrase.clickSound = phrase.pace === 'tempo' - ? settings.rhythm.sound - : settings.rhythm.active || phrase.clickSound; + ? (sync ? settings.rhythm.active && settings.rhythm.sound : false) + : settings.rhythm.active && settings.rhythm.sound; const setPhrase = useCallback((updates: Partial) => { updateSettings(s => { const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } }; @@ -197,6 +199,7 @@ export const PhraseTrainer = forwardRef( meter: { numerator: phrase.meterNumerator, denominator: 4 }, bars: phrase.arpeggioBars, progression: phrase.arpeggioProgression, + chordSelection: phrase.arpeggioChordSelection as ArpeggioDegree[], rhythm: phrase.arpeggioRhythm, seed: melodySeed, }, pool); @@ -214,7 +217,8 @@ export const PhraseTrainer = forwardRef( }, [phrase.material, phrase.keyTonic, phrase.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, phrase.arpeggioRhythm, melodySeed, + phrase.arpeggioBars, phrase.arpeggioProgression, phrase.arpeggioRhythm, + phrase.arpeggioChordSelection, melodySeed, melodySeed, pool, importedScore]); const fullScore: Result | null = useMemo(() => { @@ -631,6 +635,10 @@ export const PhraseTrainer = forwardRef( + + + + @@ -654,6 +662,35 @@ export const PhraseTrainer = forwardRef( )} + {phrase.arpeggioBars > 1 && ( +
+ + Chord pool ({phrase.arpeggioChordSelection.length === 0 ? 'all 7' : `${phrase.arpeggioChordSelection.length} selected`}): + +
+ {(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => { + const active = phrase.arpeggioChordSelection.length === 0 || phrase.arpeggioChordSelection.includes(d); + return ( + + ); + })} +
+
+ )}