Files
sheetmusictrainer/src/components/MetronomeWidget.tsx
Jens Mohrmann 71271387ab Fix double metronome click in single-note modes
useGameLogic's own scheduler and the footer widget's scheduler both
played audible clicks while rhythm.active — assist->learn-notes switch
made the second one mount. Now the widget is the single click source in
non-zen modes; useGameLogic's scheduler is a silent tick source for
auto-advance (full sound only in zen mode, where the widget isn't
rendered). Also: widget honors Timer mode (60/seconds) like the trainer,
and useMetronome skips audio nodes at volume 0.
2026-09-20 00:27:59 +02:00

240 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<RhythmSettings>) => 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<MetronomeWidgetProps> = ({
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;
// Phrase mode (syncMode 'option') has no on/off switch — the sync
// checkbox replaces it (unchecked = free metronome, checked = synced to
// the exercise), so the metronome is always armed there. Gating on
// rhythm.active would leave phrase users with no way to enable clicks.
const armed = syncMode === 'option' ? true : rhythm.active;
// 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 : armed;
// Timer mode = one beat per N seconds: effective BPM 60/seconds so
// clicks and pendulum match the trainer's auto-advance cadence.
const effectiveBpm = rhythm.mode === 'bpm' ? rhythm.bpm : 60 / Math.max(1, rhythm.seconds);
const { restart } = useMetronome({
bpm: effectiveBpm,
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, effectiveBpm);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Header: label + mode toggle */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label" style={{ marginBottom: 0 }}>
<span>Metronome</span>
</label>
{syncMode === 'option' ? (
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={!!rhythm.syncToExercise}
onChange={(e) => onUpdate({ syncToExercise: e.target.checked })}
/>
Sync to exercise
</label>
) : (
<button
className={`switch-button ${rhythm.active ? 'active' : ''}`}
onClick={() => onUpdate({ active: !rhythm.active })}
title={rhythm.active ? 'Turn Off' : 'Turn On (sound + pendulum)'}
>
<div className="switch-thumb" />
</button>
)}
</div>
{/* Always visible body; dimmed when disarmed */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)', opacity: armed ? 1 : 0.5 }}>
{/* Pendulum */}
<div
style={{
position: 'relative',
height: compact ? 14 : 18,
borderRadius: 9,
background: 'color-mix(in srgb, var(--color-text-main) 8%, transparent)',
overflow: 'hidden',
}}
aria-hidden
>
<div
style={{
position: 'absolute',
top: '50%',
left: beatParity === 0 ? '6%' : '88%',
width: compact ? 10 : 12,
height: compact ? 10 : 12,
borderRadius: '50%',
background: 'var(--color-text-main)',
transform: 'translateY(-50%)',
transition: `left ${beatMs}ms linear`,
display: armed && running ? 'block' : 'none',
}}
/>
</div>
{/* BPM / Timer controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{!compact && (
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
<button
className={`control-button small ${rhythm.mode === 'bpm' ? 'active' : ''}`}
onClick={() => onUpdate({ mode: 'bpm' })}
style={{ fontSize: '10px' }}
>BPM</button>
<button
className={`control-button small ${rhythm.mode === 'seconds' ? 'active' : ''}`}
onClick={() => onUpdate({ mode: 'seconds' })}
style={{ fontSize: '10px' }}
>Timer</button>
</div>
)}
{rhythm.mode === 'bpm' ? (
<>
<button
className="control-button small"
onClick={() => onUpdate({ bpm: Math.max(30, rhythm.bpm - 1) })}
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>−</button>
<input
type="number"
min="30"
max="300"
value={rhythm.bpm}
onChange={(e) => onUpdate({ bpm: Math.max(1, parseInt(e.target.value) || 60) })}
className="control-input"
style={{ width: '48px', textAlign: 'center', padding: '2px' }}
/>
<button
className="control-button small"
onClick={() => onUpdate({ bpm: Math.min(300, rhythm.bpm + 1) })}
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>+</button>
<input
type="range"
min="30"
max="240"
step="1"
value={rhythm.bpm}
onChange={(e) => onUpdate({ bpm: Number(e.target.value) })}
style={{ flex: 1 }}
/>
</>
) : (
<>
<span style={{ fontSize: '12px', minWidth: '32px' }}>{rhythm.seconds}s</span>
<input
type="range"
min="1"
max="60"
step="1"
value={rhythm.seconds}
onChange={(e) => onUpdate({ seconds: Number(e.target.value) })}
style={{ flex: 1 }}
/>
</>
)}
</div>
{rhythm.mode === 'bpm' && (
<div style={{ fontSize: '11px', opacity: 0.7, textAlign: 'center' }}>
{getTempoMarking(rhythm.bpm)}
</div>
)}
{/* Options row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{showAutoAdvance ? (
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={rhythm.autoAdvance}
onChange={(e) => onUpdate({ autoAdvance: e.target.checked })}
/>
Auto
</label>
) : <span />}
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={rhythm.sound}
onChange={(e) => onUpdate({ sound: e.target.checked })}
/>
Click sound
</label>
</div>
</div>
</div>
);
};