mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
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:
21
src/hooks/phraseBeatBus.ts
Normal file
21
src/hooks/phraseBeatBus.ts
Normal 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
26
src/hooks/phraseRunBus.ts
Normal 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); };
|
||||
},
|
||||
};
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user