/** * Metronome widget — always-visible tempo controls + pendulum. * * 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. * * 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'; import { getTempoMarking } from '../music/TempoMarkings'; import { phraseBeatBus } from '../hooks/phraseBeatBus'; import { audioEngine } from '../audio/AudioEngine'; import type { RhythmSettings } from '../types/SettingsTypes'; interface MetronomeWidgetProps { rhythm: RhythmSettings; /** 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; /** 'option': show the sync checkbox (Phrase Mode) that replaces the pace * dropdown: checked = clicks/beats only with the exercise (preview & * run, count-in included); unchecked = free-running metronome. * null: plain on/off (single-note modes). */ syncMode?: 'option' | null; /** External gate (sync mode): true while the exercise runs. */ gate?: boolean | null; } export const MetronomeWidget: React.FC = ({ rhythm, onUpdate, showAutoAdvance = false, compact = false, syncMode = null, gate = null, }) => { const [beatParity, setBeatParity] = useState(0); const lastTickRef = useRef(0); const handleTick = useCallback((beat: number) => { const now = performance.now(); if (now - lastTickRef.current < 60) return; // glitch guard lastTickRef.current = now; setBeatParity(beat % 2); }, []); 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; const { restart } = useMetronome({ bpm: rhythm.bpm, volume: rhythm.sound ? rhythm.volume : 0, playing: running && !isSync, onTick: handleTick, }); void restart; // Sync: pendulum mirrors the phrase run's beats (audio-time scheduled). useEffect(() => { 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); }); }, [isSync, runActive]); const beatMs = 60000 / Math.max(1, rhythm.bpm); const armed = rhythm.active; return (
{/* Header: label + mode toggle */}
{syncMode === 'option' ? ( ) : ( )}
{/* Always visible body; dimmed when disarmed */}
{/* Pendulum */}
{/* BPM / Timer controls */}
{!compact && (
)} {rhythm.mode === 'bpm' ? ( <> 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 }} /> ) : ( <> {rhythm.seconds}s onUpdate({ seconds: Number(e.target.value) })} style={{ flex: 1 }} /> )}
{rhythm.mode === 'bpm' && (
{getTempoMarking(rhythm.bpm)}
)} {/* Options row */}
{showAutoAdvance ? ( ) : }
); };