mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
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