From 2ed1a881843aa60304ade3ee40805e5921e2f81a Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Mon, 14 Sep 2026 16:33:39 +0000 Subject: [PATCH] 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. --- src/components/Controls.tsx | 8 +++++++- src/components/MetronomeWidget.tsx | 25 +++++++++++++++++++++---- src/components/PhraseTrainer.tsx | 20 ++++++++++++++------ src/game/PhraseTransport.ts | 5 +++-- src/hooks/phraseBeatBus.ts | 21 +++++++++++++++++++++ src/hooks/phraseRunBus.ts | 26 ++++++++++++++++++++++++++ src/hooks/usePhraseTrainer.ts | 17 ++++++++++++++++- 7 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 src/hooks/phraseBeatBus.ts create mode 100644 src/hooks/phraseRunBus.ts diff --git a/src/components/Controls.tsx b/src/components/Controls.tsx index 6718a29..7c851d5 100644 --- a/src/components/Controls.tsx +++ b/src/components/Controls.tsx @@ -1,3 +1,4 @@ +import React, { useEffect, useState } from 'react'; import { Guitar, Music, Gauge } from 'lucide-react'; import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; @@ -5,7 +6,7 @@ import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs'; import { TuningMeter } from './TuningMeter'; import { TargetNoteControls } from './TargetNoteControls'; import { MetronomeWidget } from './MetronomeWidget'; - +import { phraseRunBus } from '../hooks/phraseRunBus'; import { useSettings } from '../context/useSettings'; import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes'; @@ -19,6 +20,10 @@ export const Controls: React.FC = ({ currentPitch }) => { const { settings, updateSettings } = useSettings(); // Alias to minimize refactor, or just use updateSettings. const onUpdateSettings = updateSettings; + // Phrase run gate: the metronome only ticks while the phrase trainer is + // counting in / playing (armed by the user toggle, fired by the run). + const [phraseRunActive, setPhraseRunActive] = useState(false); + useEffect(() => phraseRunBus.subscribe(setPhraseRunActive), []); const handleTuningChange = (e: React.ChangeEvent) => { onUpdateSettings({ ...settings, tuningId: e.target.value }); }; @@ -176,6 +181,7 @@ export const Controls: React.FC = ({ currentPitch }) => { rhythm={settings.rhythm} onUpdate={updateRhythm} showAutoAdvance={!inPhraseMode} + gate={inPhraseMode ? phraseRunActive : null} /> diff --git a/src/components/MetronomeWidget.tsx b/src/components/MetronomeWidget.tsx index 28c0303..b4cd0e9 100644 --- a/src/components/MetronomeWidget.tsx +++ b/src/components/MetronomeWidget.tsx @@ -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 = ({ @@ -28,9 +33,12 @@ export const MetronomeWidget: React.FC = ({ 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 = ({ 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; diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 07cf193..3a97fb9 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -80,10 +80,13 @@ 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": phrase tempo pace reuses the common metronome - // click — never both (the phrase clickSound is disabled in that case). + // "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' && phrase.clickSound && !metronomeActive; + phrase.clickSound = phrase.pace === 'tempo' ? true : phrase.clickSound && metronomeActive; const setPhrase = useCallback((updates: Partial) => { updateSettings(s => { const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } }; @@ -690,13 +693,18 @@ export const PhraseTrainer = forwardRef( Virtual input only {phrase.pace === 'tempo' && ( + + Clicks come from the run (with count-in); the metronome widget mirrors the beats. + + )} + {phrase.pace === 'step' && ( )} - {phrase.pace === 'tempo' && phrase.clickSound && ( -

Phrase click active — switch the metronome off to avoid double clicks, or untick this to reuse the metronome click.

+ {phrase.pace === 'tempo' && ( +

The run clicks a full count-in bar, then one click per beat. The metronome pendulum mirrors these beats — set the tempo on the metronome tool.

)}

Matching checks pitch, not fingering — several fretboard positions produce the same pitch. diff --git a/src/game/PhraseTransport.ts b/src/game/PhraseTransport.ts index 38814c9..1ae2ae3 100644 --- a/src/game/PhraseTransport.ts +++ b/src/game/PhraseTransport.ts @@ -16,6 +16,7 @@ export interface ScheduleEntry { readonly at: number; /** Beat number within the count-in (count-in-beat only). */ readonly beat?: number; + readonly accent?: boolean; /** Event index (note-end only). */ readonly index?: number; } @@ -41,7 +42,7 @@ export function buildSchedule(score: Score, bpm: number, startAt: number, countI const entries: ScheduleEntry[] = []; for (let i = 0; i < countInBeats; i++) { - entries.push({ kind: 'count-in-beat', at: startAt + i * spb, beat: i }); + entries.push({ kind: 'count-in-beat', at: startAt + i * spb, beat: i, accent: i === 0 }); } const scoreStart = startAt + countInBeats * spb; @@ -53,7 +54,7 @@ export function buildSchedule(score: Score, bpm: number, startAt: number, countI const barTicks = score.meter.numerator * PPQ; for (let i = 0; i < beatCount; i++) { const tick = i * PPQ; - entries.push({ kind: 'beat', at: scoreStart + (tick / PPQ) * spb, beat: (tick % barTicks === 0) ? i : undefined, index: i }); + entries.push({ kind: 'beat', at: scoreStart + (tick / PPQ) * spb, beat: i, accent: tick % barTicks === 0, index: i }); } scoreEvents(score).forEach((e, i) => { diff --git a/src/hooks/phraseBeatBus.ts b/src/hooks/phraseBeatBus.ts new file mode 100644 index 0000000..c696939 --- /dev/null +++ b/src/hooks/phraseBeatBus.ts @@ -0,0 +1,21 @@ +/** + * Beat clock shared between the phrase trainer's scheduler and the global + * metronome widget. The phrase run emits every beat (count-in + score beats) + * here; the metronome widget mirrors them on its pendulum so the visual + * stays in sync with the audible phrase click. Beats carry an absolute + * AudioContext timestamp so the widget can schedule the visual swing at the + * right moment even when callbacks fire ahead of time. + */ +type BeatListener = (beat: number, at: number) => void; + +const listeners = new Set(); + +export const phraseBeatBus = { + emit(beat: number, at: number): void { + listeners.forEach(l => l(beat, at)); + }, + subscribe(l: BeatListener): () => void { + listeners.add(l); + return () => { listeners.delete(l); }; + }, +}; diff --git a/src/hooks/phraseRunBus.ts b/src/hooks/phraseRunBus.ts new file mode 100644 index 0000000..b894f5a --- /dev/null +++ b/src/hooks/phraseRunBus.ts @@ -0,0 +1,26 @@ +/** + * Tiny pub/sub for the phrase trainer's running state. + * + * Lets the (global) metronome widget gate its pendulum/click on the phrase + * run: in Phrase Mode the metronome should only be active while the trainer + * is counting in or playing, not while idle. + */ +type Listener = (running: boolean) => void; + +const listeners = new Set(); +let running = false; + +export const phraseRunBus = { + set(v: boolean): void { + if (v === running) return; + running = v; + listeners.forEach(l => l(v)); + }, + get(): boolean { + return running; + }, + subscribe(l: Listener): () => void { + listeners.add(l); + return () => { listeners.delete(l); }; + }, +}; diff --git a/src/hooks/usePhraseTrainer.ts b/src/hooks/usePhraseTrainer.ts index 770aa5e..6f59168 100644 --- a/src/hooks/usePhraseTrainer.ts +++ b/src/hooks/usePhraseTrainer.ts @@ -18,6 +18,8 @@ import { buildSchedule, sanitizeBpm, type ScheduleEntry } from '../game/PhraseTr import { PPQ } from '../score/model'; import { audioEngine } from '../audio/AudioEngine'; import { subscribeRawFrames } from './rawFrameBus'; +import { phraseRunBus } from './phraseRunBus'; +import { phraseBeatBus } from './phraseBeatBus'; export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | 'done'; @@ -118,6 +120,9 @@ export function usePhraseTrainer( const setPhaseBoth = useCallback((p: PhrasePhase) => { phaseRef.current = p; setPhase(p); + // Expose run state so the shared metronome can gate its click/pendulum: + // only running during count-in and playing, never idle/preview/paused. + phraseRunBus.set(p === 'countIn' || p === 'playing'); }, []); useEffect(() => { paceRef.current = config.pace; }, [config.pace]); @@ -197,7 +202,17 @@ export function usePhraseTrainer( countInBeatsRef.current > 0 && e.at <= scoreStartRef.current + 0.02; if ((e.kind === 'count-in-beat' || e.kind === 'beat') && !isRedundantDownbeat && configRef.current.clickSound) { - audioEngine.playClickAt(e.at, CLICK_VOLUME, e.kind === 'count-in-beat' ? e.beat === 0 : e.beat !== undefined); + audioEngine.playClickAt(e.at, CLICK_VOLUME, e.kind === 'count-in-beat' ? e.beat === 0 : e.accent === true); + } + // Publish every beat to the shared bus so the metronome widget's + // pendulum stays in sync with the run (click or no click). + if (e.kind === 'count-in-beat' || e.kind === 'beat') { + phraseBeatBus.emit(e.beat ?? 0, e.at); + } + // Publish every beat to the shared bus so the metronome widget's + // pendulum stays in sync with the run (click or no click). + if (e.kind === 'count-in-beat' || e.kind === 'beat') { + phraseBeatBus.emit(e.kind === 'count-in-beat' ? (e.beat ?? 0) : ((e.beat ?? 0) + countInBeatsRef.current), e.at); } clickIdxRef.current++; }