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

@@ -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<ControlsProps> = ({ 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<HTMLSelectElement>) => {
onUpdateSettings({ ...settings, tuningId: e.target.value });
};
@@ -176,6 +181,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
rhythm={settings.rhythm}
onUpdate={updateRhythm}
showAutoAdvance={!inPhraseMode}
gate={inPhraseMode ? phraseRunActive : null}
/>
</div>

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;

View File

@@ -80,10 +80,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
// (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<PhraseSettings>) => {
updateSettings(s => {
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
@@ -690,13 +693,18 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
Virtual input only
</label>
{phrase.pace === 'tempo' && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Clicks come from the run (with count-in); the metronome widget mirrors the beats.
</span>
)}
{phrase.pace === 'step' && (
<label className="phrase-check">
<input
type="checkbox"
checked={phrase.clickSound}
onChange={e => setPhrase({ clickSound: e.target.checked })}
/>
Separate phrase click (off = use the metronome click)
Metronome click while practicing (requires metronome on)
</label>
)}
<label className="phrase-check">
@@ -708,8 +716,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
Auto-continue
</label>
</div>
{phrase.pace === 'tempo' && phrase.clickSound && (
<p className="phrase-note">Phrase click active — switch the metronome off to avoid double clicks, or untick this to reuse the metronome click.</p>
{phrase.pace === 'tempo' && (
<p className="phrase-note">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.</p>
)}
<p className="phrase-note">
Matching checks pitch, not fingering — several fretboard positions produce the same pitch.

View File

@@ -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) => {

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