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

@@ -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<BeatListener>();
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); };
},
};

26
src/hooks/phraseRunBus.ts Normal file
View File

@@ -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<Listener>();
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); };
},
};

View File

@@ -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++;
}