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 { Guitar, Music, Gauge } from 'lucide-react';
|
||||||
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
|
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
|
||||||
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
||||||
@@ -5,7 +6,7 @@ import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
|||||||
import { TuningMeter } from './TuningMeter';
|
import { TuningMeter } from './TuningMeter';
|
||||||
import { TargetNoteControls } from './TargetNoteControls';
|
import { TargetNoteControls } from './TargetNoteControls';
|
||||||
import { MetronomeWidget } from './MetronomeWidget';
|
import { MetronomeWidget } from './MetronomeWidget';
|
||||||
|
import { phraseRunBus } from '../hooks/phraseRunBus';
|
||||||
import { useSettings } from '../context/useSettings';
|
import { useSettings } from '../context/useSettings';
|
||||||
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes';
|
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes';
|
||||||
|
|
||||||
@@ -19,6 +20,10 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
const { settings, updateSettings } = useSettings();
|
const { settings, updateSettings } = useSettings();
|
||||||
// Alias to minimize refactor, or just use updateSettings.
|
// Alias to minimize refactor, or just use updateSettings.
|
||||||
const onUpdateSettings = 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>) => {
|
const handleTuningChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
onUpdateSettings({ ...settings, tuningId: e.target.value });
|
onUpdateSettings({ ...settings, tuningId: e.target.value });
|
||||||
};
|
};
|
||||||
@@ -176,6 +181,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
rhythm={settings.rhythm}
|
rhythm={settings.rhythm}
|
||||||
onUpdate={updateRhythm}
|
onUpdate={updateRhythm}
|
||||||
showAutoAdvance={!inPhraseMode}
|
showAutoAdvance={!inPhraseMode}
|
||||||
|
gate={inPhraseMode ? phraseRunActive : null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useMetronome } from '../hooks/useMetronome';
|
import { useMetronome } from '../hooks/useMetronome';
|
||||||
import { getTempoMarking } from '../music/TempoMarkings';
|
import { getTempoMarking } from '../music/TempoMarkings';
|
||||||
|
import { phraseBeatBus } from '../hooks/phraseBeatBus';
|
||||||
|
import { audioEngine } from '../audio/AudioEngine';
|
||||||
import type { RhythmSettings } from '../types/SettingsTypes';
|
import type { RhythmSettings } from '../types/SettingsTypes';
|
||||||
|
|
||||||
interface MetronomeWidgetProps {
|
interface MetronomeWidgetProps {
|
||||||
@@ -20,6 +22,9 @@ interface MetronomeWidgetProps {
|
|||||||
onBeat?: () => void;
|
onBeat?: () => void;
|
||||||
/** Compact layout (phrase transport row). */
|
/** Compact layout (phrase transport row). */
|
||||||
compact?: boolean;
|
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> = ({
|
export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
||||||
@@ -28,9 +33,12 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
showAutoAdvance = false,
|
showAutoAdvance = false,
|
||||||
onBeat,
|
onBeat,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
gate = null,
|
||||||
}) => {
|
}) => {
|
||||||
// Single metronome instance: drives both the audible click and the
|
// Gated mode (Phrase Mode): the widget does NOT run its own scheduler —
|
||||||
// pendulum parity flip (perfectly in sync).
|
// 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 [beatParity, setBeatParity] = useState(0);
|
||||||
const lastTickRef = useRef(0);
|
const lastTickRef = useRef(0);
|
||||||
const onBeatRef = useRef(onBeat);
|
const onBeatRef = useRef(onBeat);
|
||||||
@@ -45,10 +53,19 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
onBeatRef.current?.();
|
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({
|
const { restart } = useMetronome({
|
||||||
bpm: rhythm.bpm,
|
bpm: rhythm.bpm,
|
||||||
volume: rhythm.sound ? rhythm.volume : 0,
|
volume: rhythm.sound && gate === null ? rhythm.volume : 0,
|
||||||
playing: rhythm.active,
|
playing: rhythm.active && (gate === null || gate),
|
||||||
onTick: handleTick,
|
onTick: handleTick,
|
||||||
});
|
});
|
||||||
void restart;
|
void restart;
|
||||||
|
|||||||
@@ -80,10 +80,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
// (single source of truth across modes; the phrase-local BPM control
|
// (single source of truth across modes; the phrase-local BPM control
|
||||||
// was replaced by the common metronome widget in Controls).
|
// was replaced by the common metronome widget in Controls).
|
||||||
phrase.bpm = settings.rhythm.bpm;
|
phrase.bpm = settings.rhythm.bpm;
|
||||||
// "Sync to metronome": phrase tempo pace reuses the common metronome
|
// "Sync to metronome": the phrase run IS the click source while it
|
||||||
// click — never both (the phrase clickSound is disabled in that case).
|
// 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;
|
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>) => {
|
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
|
||||||
updateSettings(s => {
|
updateSettings(s => {
|
||||||
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
|
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
|
||||||
@@ -690,13 +693,18 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
Virtual input only
|
Virtual input only
|
||||||
</label>
|
</label>
|
||||||
{phrase.pace === 'tempo' && (
|
{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">
|
<label className="phrase-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={phrase.clickSound}
|
checked={phrase.clickSound}
|
||||||
onChange={e => setPhrase({ clickSound: e.target.checked })}
|
onChange={e => setPhrase({ clickSound: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
Separate phrase click (off = use the metronome click)
|
Metronome click while practicing (requires metronome on)
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
<label className="phrase-check">
|
<label className="phrase-check">
|
||||||
@@ -708,8 +716,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
Auto-continue
|
Auto-continue
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{phrase.pace === 'tempo' && phrase.clickSound && (
|
{phrase.pace === 'tempo' && (
|
||||||
<p className="phrase-note">Phrase click active — switch the metronome off to avoid double clicks, or untick this to reuse the metronome click.</p>
|
<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">
|
<p className="phrase-note">
|
||||||
Matching checks pitch, not fingering — several fretboard positions produce the same pitch.
|
Matching checks pitch, not fingering — several fretboard positions produce the same pitch.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface ScheduleEntry {
|
|||||||
readonly at: number;
|
readonly at: number;
|
||||||
/** Beat number within the count-in (count-in-beat only). */
|
/** Beat number within the count-in (count-in-beat only). */
|
||||||
readonly beat?: number;
|
readonly beat?: number;
|
||||||
|
readonly accent?: boolean;
|
||||||
/** Event index (note-end only). */
|
/** Event index (note-end only). */
|
||||||
readonly index?: number;
|
readonly index?: number;
|
||||||
}
|
}
|
||||||
@@ -41,7 +42,7 @@ export function buildSchedule(score: Score, bpm: number, startAt: number, countI
|
|||||||
const entries: ScheduleEntry[] = [];
|
const entries: ScheduleEntry[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < countInBeats; i++) {
|
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;
|
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;
|
const barTicks = score.meter.numerator * PPQ;
|
||||||
for (let i = 0; i < beatCount; i++) {
|
for (let i = 0; i < beatCount; i++) {
|
||||||
const tick = i * PPQ;
|
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) => {
|
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 { PPQ } from '../score/model';
|
||||||
import { audioEngine } from '../audio/AudioEngine';
|
import { audioEngine } from '../audio/AudioEngine';
|
||||||
import { subscribeRawFrames } from './rawFrameBus';
|
import { subscribeRawFrames } from './rawFrameBus';
|
||||||
|
import { phraseRunBus } from './phraseRunBus';
|
||||||
|
import { phraseBeatBus } from './phraseBeatBus';
|
||||||
|
|
||||||
export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | 'done';
|
export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | 'done';
|
||||||
|
|
||||||
@@ -118,6 +120,9 @@ export function usePhraseTrainer(
|
|||||||
const setPhaseBoth = useCallback((p: PhrasePhase) => {
|
const setPhaseBoth = useCallback((p: PhrasePhase) => {
|
||||||
phaseRef.current = p;
|
phaseRef.current = p;
|
||||||
setPhase(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]);
|
useEffect(() => { paceRef.current = config.pace; }, [config.pace]);
|
||||||
@@ -197,7 +202,17 @@ export function usePhraseTrainer(
|
|||||||
countInBeatsRef.current > 0 &&
|
countInBeatsRef.current > 0 &&
|
||||||
e.at <= scoreStartRef.current + 0.02;
|
e.at <= scoreStartRef.current + 0.02;
|
||||||
if ((e.kind === 'count-in-beat' || e.kind === 'beat') && !isRedundantDownbeat && configRef.current.clickSound) {
|
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++;
|
clickIdxRef.current++;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user