Metronome/phrase click unification: run-driven beats, gated widget

- Phrase Mode (sync to metronome): the run's scheduler is the single click
  source (with count-in); the metronome widget is muted and its pendulum
  mirrors the run's beats via the new phraseBeatBus (audio-timestamp
  scheduled). No more double clicks; clicks only while the run is active
  (phraseRunBus gates the widget's own ticking).
- Schedule entries now carry continuous beat index + accent flag (downbeats
  accented, count-in beat 1 accented).
- 'At your pace': optional click via the metronome toggle (kept explicit).
- Metronome widget: gate prop; in gated mode it listens to the beat bus
  instead of running its own scheduler.
This commit is contained in:
OpenClaw
2026-09-14 16:33:39 +00:00
parent 57a6a654a0
commit 2ed1a88184
7 changed files with 108 additions and 14 deletions

View File

@@ -9,6 +9,8 @@
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 {
@@ -20,6 +22,9 @@ interface MetronomeWidgetProps {
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. */
gate?: boolean | null;
}
export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
@@ -28,9 +33,12 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
showAutoAdvance = false,
onBeat,
compact = false,
gate = null,
}) => {
// Single metronome instance: drives both the audible click and the
// pendulum parity flip (perfectly in sync).
// 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);
@@ -45,10 +53,19 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
onBeatRef.current?.();
}, []);
// Gated: mirror phrase-run beats onto the pendulum at their audio times.
useEffect(() => {
if (gate === null || !gate) 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 ? rhythm.volume : 0,
playing: rhythm.active,
volume: rhythm.sound && gate === null ? rhythm.volume : 0,
playing: rhythm.active && (gate === null || gate),
onTick: handleTick,
});
void restart;