mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Metronome rewrite: single sound checkbox, clean click architecture
- MetronomeWidget rebuilt cleanly: ONE 'Click sound' checkbox (the earlier restructure left duplicated option rows behind — neither worked). Widget body always visible (dimmed when disarmed); toggle arms sound+pendulum. - Click decision unified: 'sync to metronome' derives phrase clickSound from the widget's sound checkbox (no separate phrase click option); 'at your pace' clicks when the metronome is armed. - Chord selection: abwählbare Liste (arpeggioChordSelection) restricts progression chords; first bar draws a random chord FROM THE SELECTION (fixed first chord repeated the exercise identically). - Scales: rhythm setting (quarters/eighths) shared with arpeggios; drill id includes rhythm to invalidate cached scores. - Beam fix: register Beam via note.setBeam so VexFlow suppresses flags (eighths/16ths previously drew BOTH beam and flag).
This commit is contained in:
@@ -1,10 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Metronome widget with a linear pendulum indicator.
|
* Metronome widget — always-visible tempo controls + pendulum.
|
||||||
*
|
*
|
||||||
* The pendulum is a CSS-animated dot whose half-period equals one beat:
|
* One toggle ("On") arms sound + animation. One `sound` checkbox decides
|
||||||
* it travels left→right on even beats and right→left on odd beats, driven
|
* whether an armed metronome clicks (the pendulum always swings when armed,
|
||||||
* by the metronome's onTick callback (no drift — synced to the audible
|
* giving silent visual tempo). There is exactly ONE sound checkbox here.
|
||||||
* click, not to wall-clock CSS timing alone).
|
*
|
||||||
|
* Click sources (no doubling possible):
|
||||||
|
* - Ungated (single-note modes, phrase "at your pace"): this widget's own
|
||||||
|
* scheduler clicks when armed && sound.
|
||||||
|
* - Gated (phrase "sync to metronome"): the phrase run's scheduler owns the
|
||||||
|
* clock; this widget is silent and its pendulum mirrors phrase beats.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useMetronome } from '../hooks/useMetronome';
|
import { useMetronome } from '../hooks/useMetronome';
|
||||||
@@ -19,11 +24,10 @@ interface MetronomeWidgetProps {
|
|||||||
onUpdate: (updates: Partial<RhythmSettings>) => void;
|
onUpdate: (updates: Partial<RhythmSettings>) => void;
|
||||||
/** Single-note mode extra: auto-advance targets on each tick. */
|
/** Single-note mode extra: auto-advance targets on each tick. */
|
||||||
showAutoAdvance?: boolean;
|
showAutoAdvance?: boolean;
|
||||||
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
|
/** External gate (Phrase Mode "sync to metronome"): the widget is muted
|
||||||
* phrase run is active — user toggle arms it, the gate fires it. */
|
* and the pendulum mirrors the run's beats. null = free-running. */
|
||||||
gate?: boolean | null;
|
gate?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,49 +35,44 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
rhythm,
|
rhythm,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
showAutoAdvance = false,
|
showAutoAdvance = false,
|
||||||
onBeat,
|
|
||||||
compact = false,
|
compact = false,
|
||||||
gate = null,
|
gate = null,
|
||||||
}) => {
|
}) => {
|
||||||
// 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 [beatParity, setBeatParity] = useState(0);
|
||||||
const lastTickRef = useRef(0);
|
const lastTickRef = useRef(0);
|
||||||
const onBeatRef = useRef(onBeat);
|
|
||||||
useEffect(() => { onBeatRef.current = onBeat; }, [onBeat]);
|
|
||||||
|
|
||||||
const handleTick = useCallback((beat: number) => {
|
const handleTick = useCallback((beat: number) => {
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
// Ignore double-fires within 60ms (audio glitch guard)
|
if (now - lastTickRef.current < 60) return; // glitch guard
|
||||||
if (now - lastTickRef.current < 60) return;
|
|
||||||
lastTickRef.current = now;
|
lastTickRef.current = now;
|
||||||
setBeatParity(beat % 2);
|
setBeatParity(beat % 2);
|
||||||
onBeatRef.current?.();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Gated: mirror phrase-run beats onto the pendulum at their audio times.
|
const gated = gate !== null;
|
||||||
|
const running = rhythm.active && (gate === null || gate);
|
||||||
|
|
||||||
|
// Own click scheduler — only when NOT gated.
|
||||||
|
const { restart } = useMetronome({
|
||||||
|
bpm: rhythm.bpm,
|
||||||
|
volume: rhythm.sound ? rhythm.volume : 0,
|
||||||
|
playing: running && !gated,
|
||||||
|
onTick: handleTick,
|
||||||
|
});
|
||||||
|
void restart;
|
||||||
|
|
||||||
|
// Gated: pendulum mirrors the phrase run's beats (audio-time scheduled).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (gate === null || !gate) return;
|
if (!gated || !running) return;
|
||||||
return phraseBeatBus.subscribe((_beat, at) => {
|
return phraseBeatBus.subscribe((_beat, at) => {
|
||||||
const delay = Math.max(0, (at - audioEngine.now()) * 1000);
|
const delay = Math.max(0, (at - audioEngine.now()) * 1000);
|
||||||
window.setTimeout(() => setBeatParity(p => (p + 1) % 2), delay);
|
window.setTimeout(() => setBeatParity(p => (p + 1) % 2), delay);
|
||||||
});
|
});
|
||||||
}, [gate]);
|
}, [gated, running]);
|
||||||
|
|
||||||
const { restart } = useMetronome({
|
|
||||||
bpm: rhythm.bpm,
|
|
||||||
volume: rhythm.sound && gate === null ? rhythm.volume : 0,
|
|
||||||
playing: rhythm.active && (gate === null || gate),
|
|
||||||
onTick: handleTick,
|
|
||||||
});
|
|
||||||
void restart;
|
|
||||||
|
|
||||||
const beatMs = 60000 / Math.max(1, rhythm.bpm);
|
const beatMs = 60000 / Math.max(1, rhythm.bpm);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
{/* Header: label + single arm toggle */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
<label className="control-label" style={{ marginBottom: 0 }}>
|
<label className="control-label" style={{ marginBottom: 0 }}>
|
||||||
<span>Metronome</span>
|
<span>Metronome</span>
|
||||||
@@ -81,15 +80,15 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
<button
|
<button
|
||||||
className={`switch-button ${rhythm.active ? 'active' : ''}`}
|
className={`switch-button ${rhythm.active ? 'active' : ''}`}
|
||||||
onClick={() => onUpdate({ active: !rhythm.active })}
|
onClick={() => onUpdate({ active: !rhythm.active })}
|
||||||
title={rhythm.active ? 'Turn Off' : 'Turn On'}
|
title={rhythm.active ? 'Turn Off' : 'Turn On (sound + pendulum)'}
|
||||||
>
|
>
|
||||||
<div className="switch-thumb" />
|
<div className="switch-thumb" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Always visible: pendulum + BPM; the toggle gates sound + animation */}
|
{/* Always visible body; dimmed when disarmed */}
|
||||||
<div className="rhythm-details" style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)', opacity: rhythm.active ? 1 : 0.5 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)', opacity: rhythm.active ? 1 : 0.5 }}>
|
||||||
{/* Linear pendulum — same element, left transitions each beat */}
|
{/* Pendulum */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -116,6 +115,7 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* BPM / Timer controls */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
|
||||||
@@ -137,9 +137,7 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
className="control-button small"
|
className="control-button small"
|
||||||
onClick={() => onUpdate({ bpm: Math.max(30, rhythm.bpm - 1) })}
|
onClick={() => onUpdate({ bpm: Math.max(30, rhythm.bpm - 1) })}
|
||||||
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
>
|
>−</button>
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="30"
|
min="30"
|
||||||
@@ -153,9 +151,7 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
className="control-button small"
|
className="control-button small"
|
||||||
onClick={() => onUpdate({ bpm: Math.min(300, rhythm.bpm + 1) })}
|
onClick={() => onUpdate({ bpm: Math.min(300, rhythm.bpm + 1) })}
|
||||||
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
>
|
>+</button>
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="30"
|
min="30"
|
||||||
@@ -187,7 +183,8 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center' }}>
|
{/* Options row */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
{showAutoAdvance ? (
|
{showAutoAdvance ? (
|
||||||
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||||
<input
|
<input
|
||||||
@@ -204,31 +201,10 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
checked={rhythm.sound}
|
checked={rhythm.sound}
|
||||||
onChange={(e) => onUpdate({ sound: e.target.checked })}
|
onChange={(e) => onUpdate({ sound: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
Sound
|
Click sound
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
{showAutoAdvance ? (
|
|
||||||
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={rhythm.autoAdvance}
|
|
||||||
onChange={(e) => onUpdate({ autoAdvance: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Auto
|
|
||||||
</label>
|
|
||||||
) : <span />}
|
|
||||||
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={rhythm.sound}
|
|
||||||
onChange={(e) => onUpdate({ sound: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Sound
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -338,6 +338,9 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Beams: consecutive eighth/16th pairs within one beat ---
|
// --- Beams: consecutive eighth/16th pairs within one beat ---
|
||||||
|
// Note: the Beam must be registered on each note via setBeam,
|
||||||
|
// otherwise VexFlow still draws flags (beam === undefined in
|
||||||
|
// shouldDrawFlag) and you get BOTH flag and beam.
|
||||||
const beamed = frags.filter(f => (f.isEighth || f.isSixteenth) && f.note instanceof StaveNote);
|
const beamed = frags.filter(f => (f.isEighth || f.isSixteenth) && f.note instanceof StaveNote);
|
||||||
for (let i = 0; i + 1 < beamed.length; i++) {
|
for (let i = 0; i + 1 < beamed.length; i++) {
|
||||||
const a = beamed[i], b2 = beamed[i + 1];
|
const a = beamed[i], b2 = beamed[i + 1];
|
||||||
@@ -350,6 +353,8 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
|
|||||||
const beatB = Math.floor((b2.start - measureStart) / 480);
|
const beatB = Math.floor((b2.start - measureStart) / 480);
|
||||||
if (beatA === beatB) {
|
if (beatA === beatB) {
|
||||||
const beam = new Beam([a.note, b2.note], false);
|
const beam = new Beam([a.note, b2.note], false);
|
||||||
|
a.note.setBeam(beam);
|
||||||
|
b2.note.setBeam(beam);
|
||||||
beam.setContext(context).draw();
|
beam.setContext(context).draw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,13 +82,14 @@ 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": the phrase run IS the click source while it
|
// "Sync to metronome" (tempo): the phrase run owns the clock and the
|
||||||
// plays (scheduler-driven, with count-in) — the metronome widget only
|
// click decision comes from the METRONOME widget's sound checkbox —
|
||||||
// mirrors the beats visually and is muted in Phrase Mode, so clicks
|
// one place, no separate phrase click option. "At your pace" (step):
|
||||||
// can never double. The separate-click checkbox stays as an explicit
|
// the widget clicks freely when armed (metronomeActive); a legacy
|
||||||
// opt-in for a click even in "at your pace" mode.
|
// phrase.clickSound=true also allows clicks without the metronome.
|
||||||
const metronomeActive = settings.rhythm.active;
|
phrase.clickSound = phrase.pace === 'tempo'
|
||||||
phrase.clickSound = phrase.pace === 'tempo' ? true : phrase.clickSound && metronomeActive;
|
? settings.rhythm.sound
|
||||||
|
: settings.rhythm.active || phrase.clickSound;
|
||||||
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 } };
|
||||||
@@ -183,6 +184,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
direction: phrase.scaleDirection,
|
direction: phrase.scaleDirection,
|
||||||
coverage: phrase.scaleCoverage,
|
coverage: phrase.scaleCoverage,
|
||||||
meter: { numerator: phrase.meterNumerator, denominator: 4 },
|
meter: { numerator: phrase.meterNumerator, denominator: 4 },
|
||||||
|
rhythm: phrase.scaleRhythm,
|
||||||
}, pool);
|
}, pool);
|
||||||
}
|
}
|
||||||
if (phrase.material === 'arpeggio') {
|
if (phrase.material === 'arpeggio') {
|
||||||
@@ -210,7 +212,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator,
|
}, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator,
|
||||||
phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.libraryId,
|
phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId,
|
||||||
phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage,
|
phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage,
|
||||||
phrase.arpeggioBars, phrase.arpeggioProgression, phrase.arpeggioRhythm, melodySeed,
|
phrase.arpeggioBars, phrase.arpeggioProgression, phrase.arpeggioRhythm, melodySeed,
|
||||||
melodySeed, pool, importedScore]);
|
melodySeed, pool, importedScore]);
|
||||||
@@ -756,19 +758,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
</label>
|
</label>
|
||||||
{phrase.pace === 'tempo' && (
|
{phrase.pace === 'tempo' && (
|
||||||
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
|
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
|
||||||
Clicks come from the run (with count-in); the metronome widget mirrors the beats.
|
Clicks: use the metronome's "Click sound" checkbox (with count-in, synced).
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{phrase.pace === 'step' && (
|
|
||||||
<label className="phrase-check">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={phrase.clickSound}
|
|
||||||
onChange={e => setPhrase({ clickSound: e.target.checked })}
|
|
||||||
/>
|
|
||||||
Metronome click while practicing (requires metronome on)
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<label className="phrase-check">
|
<label className="phrase-check">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export interface ArpeggioConfig {
|
|||||||
readonly bars?: number;
|
readonly bars?: number;
|
||||||
readonly progression?: ArpeggioProgression;
|
readonly progression?: ArpeggioProgression;
|
||||||
readonly rhythm?: ArpeggioRhythm;
|
readonly rhythm?: ArpeggioRhythm;
|
||||||
|
/** Restrict progression chords to these degrees (empty = all 7).
|
||||||
|
* The first bar always uses `degree` if it is in the selection. */
|
||||||
|
readonly chordSelection?: readonly ArpeggioDegree[];
|
||||||
/** Seed for progression selection (deterministic). */
|
/** Seed for progression selection (deterministic). */
|
||||||
readonly seed?: number;
|
readonly seed?: number;
|
||||||
}
|
}
|
||||||
@@ -215,6 +218,9 @@ export function generateArpeggio(
|
|||||||
// Single-bar mode: the chosen degree for all bars (bars is 1).
|
// Single-bar mode: the chosen degree for all bars (bars is 1).
|
||||||
// Sequence mode: one chord per bar, chosen by progression rule.
|
// Sequence mode: one chord per bar, chosen by progression rule.
|
||||||
const degreeIndices: number[] = [];
|
const degreeIndices: number[] = [];
|
||||||
|
// Sequence chord pool: user selection (restricted to it) or all 7.
|
||||||
|
const sel = (config.chordSelection ?? []).filter(d => d in DEGREE_INDEX).map(d => DEGREE_INDEX[d as ArpeggioDegree]);
|
||||||
|
const pool7 = sel.length > 0 ? Array.from(new Set(sel)).sort((a, b) => a - b) : [0, 1, 2, 3, 4, 5, 6];
|
||||||
if (bars === 1) {
|
if (bars === 1) {
|
||||||
degreeIndices.push(DEGREE_INDEX[config.degree]);
|
degreeIndices.push(DEGREE_INDEX[config.degree]);
|
||||||
} else {
|
} else {
|
||||||
@@ -222,13 +228,19 @@ export function generateArpeggio(
|
|||||||
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
|
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
|
||||||
for (let b = 0; b < bars; b++) {
|
for (let b = 0; b < bars; b++) {
|
||||||
if (b === 0) {
|
if (b === 0) {
|
||||||
// Start on the user-chosen degree (or tonic default)
|
// First bar: a random chord FROM THE SELECTION — fixed starts
|
||||||
degreeIndices.push(DEGREE_INDEX[config.degree]);
|
// repeated the exercise identically every time.
|
||||||
|
degreeIndices.push(pool7.includes(DEGREE_INDEX[config.degree]) && rng() < 0.34
|
||||||
|
? DEGREE_INDEX[config.degree]
|
||||||
|
: pick(pool7));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const prev = degreeIndices[b - 1];
|
const prev = degreeIndices[b - 1];
|
||||||
if (config.progression === 'diatonic-cycle') {
|
if (config.progression === 'diatonic-cycle') {
|
||||||
degreeIndices.push((prev + 1) % 7);
|
// Next diatonic degree that is also in the selection
|
||||||
|
let next = (prev + 1) % 7;
|
||||||
|
for (let tries = 0; tries < 7 && !pool7.includes(next); tries++) next = (next + 1) % 7;
|
||||||
|
degreeIndices.push(next);
|
||||||
} else if (config.progression === 'functional') {
|
} else if (config.progression === 'functional') {
|
||||||
// Simple tonal function rules (major-oriented; mirrored for
|
// Simple tonal function rules (major-oriented; mirrored for
|
||||||
// minor by degree relationships below):
|
// minor by degree relationships below):
|
||||||
@@ -248,11 +260,13 @@ export function generateArpeggio(
|
|||||||
5: [1, 3],
|
5: [1, 3],
|
||||||
6: [0],
|
6: [0],
|
||||||
};
|
};
|
||||||
degreeIndices.push(pick(NEXT[prev] ?? [0]));
|
// functional: honor the selection — filter rule targets by it
|
||||||
|
const targets = (NEXT[prev] ?? [0]).filter(d => pool7.includes(d));
|
||||||
|
degreeIndices.push(pick(targets.length > 0 ? targets : pool7.filter(d => d !== prev)));
|
||||||
} else {
|
} else {
|
||||||
// random: any diatonic degree, avoid immediate repetition
|
// random: any selected degree, avoid immediate repetition
|
||||||
const candidates = [0, 1, 2, 3, 4, 5, 6].filter(d => d !== prev);
|
const candidates = pool7.filter(d => d !== prev);
|
||||||
degreeIndices.push(pick(candidates));
|
degreeIndices.push(pick(candidates.length > 0 ? candidates : pool7));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { keyFor, scalePitches, type KeyContext, type ModeId } from './scales';
|
|||||||
|
|
||||||
export type DrillDirection = 'up' | 'down' | 'updown';
|
export type DrillDirection = 'up' | 'down' | 'updown';
|
||||||
export type DrillCoverage = 'one-octave' | 'two-octave' | 'position';
|
export type DrillCoverage = 'one-octave' | 'two-octave' | 'position';
|
||||||
|
export type DrillRhythm = 'quarters' | 'eighths';
|
||||||
|
|
||||||
export interface DrillConfig {
|
export interface DrillConfig {
|
||||||
readonly keyTonic: string;
|
readonly keyTonic: string;
|
||||||
@@ -19,6 +20,8 @@ export interface DrillConfig {
|
|||||||
readonly direction: DrillDirection;
|
readonly direction: DrillDirection;
|
||||||
readonly coverage: DrillCoverage;
|
readonly coverage: DrillCoverage;
|
||||||
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
|
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
|
||||||
|
/** Note duration: quarters (default) or eighths. */
|
||||||
|
readonly rhythm?: DrillRhythm;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Complete ascending scale path from a starting tonic, if it fits the pool. */
|
/** Complete ascending scale path from a starting tonic, if it fits the pool. */
|
||||||
@@ -87,14 +90,15 @@ export function generateScaleDrill(
|
|||||||
else path = [...up, ...[...up].reverse().slice(1)]; // no duplicate turning tonic
|
else path = [...up, ...[...up].reverse().slice(1)]; // no duplicate turning tonic
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Quarter-note rhythm, final note extended to fill its bar ---------
|
// ---- Rhythm: quarters (default) or eighths; final note extended ---------
|
||||||
|
const noteDur = config.rhythm === 'eighths' ? PPQ / 2 : PPQ;
|
||||||
const full = config.meter.numerator * PPQ;
|
const full = config.meter.numerator * PPQ;
|
||||||
if (path.length * PPQ > 8 * full) {
|
if (path.length * noteDur > 8 * full) {
|
||||||
return { ok: false, error: 'This drill is longer than 8 bars — reduce the coverage or direction.' };
|
return { ok: false, error: 'This drill is longer than 8 bars — reduce the coverage, direction or note duration.' };
|
||||||
}
|
}
|
||||||
const barCount = Math.max(1, Math.ceil((path.length * PPQ) / full));
|
const barCount = Math.max(1, Math.ceil((path.length * noteDur) / full));
|
||||||
const totalTicks = barCount * full;
|
const totalTicks = barCount * full;
|
||||||
const lastDuration = PPQ + (totalTicks - path.length * PPQ);
|
const lastDuration = noteDur + (totalTicks - path.length * noteDur);
|
||||||
|
|
||||||
const events: ScoreEvent[] = [];
|
const events: ScoreEvent[] = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
@@ -104,7 +108,7 @@ export function generateScaleDrill(
|
|||||||
return found ? found.pitch : sp[0]?.pitch ?? null;
|
return found ? found.pitch : sp[0]?.pitch ?? null;
|
||||||
};
|
};
|
||||||
for (let i = 0; i < path.length; i++) {
|
for (let i = 0; i < path.length; i++) {
|
||||||
const dur = i === path.length - 1 ? lastDuration : PPQ;
|
const dur = i === path.length - 1 ? lastDuration : noteDur;
|
||||||
const pitch = spellFor(path[i]);
|
const pitch = spellFor(path[i]);
|
||||||
if (!pitch) return { ok: false, error: 'Internal error spelling drill pitch.' };
|
if (!pitch) return { ok: false, error: 'Internal error spelling drill pitch.' };
|
||||||
events.push({ id: `s${i}`, startTick: cursor, durationTicks: dur, pitch });
|
events.push({ id: `s${i}`, startTick: cursor, durationTicks: dur, pitch });
|
||||||
@@ -113,7 +117,7 @@ export function generateScaleDrill(
|
|||||||
|
|
||||||
const score: Score = {
|
const score: Score = {
|
||||||
version: 1,
|
version: 1,
|
||||||
id: `scale-${config.keyTonic}-${config.keyMode}-${config.coverage}-${config.direction}-${config.meter.numerator}4`,
|
id: `scale-${config.keyTonic}-${config.keyMode}-${config.coverage}-${config.direction}-${config.rhythm ?? 'quarters'}-${config.meter.numerator}4`,
|
||||||
title: `${key.tonic} scale — ${config.coverage === 'position' ? 'within position' : config.coverage} ${config.direction}`,
|
title: `${key.tonic} scale — ${config.coverage === 'position' ? 'within position' : config.coverage} ${config.direction}`,
|
||||||
meter: config.meter,
|
meter: config.meter,
|
||||||
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
|
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
|
||||||
|
|||||||
@@ -53,8 +53,11 @@ export interface PhraseSettings {
|
|||||||
rhythmLevel: 1 | 2 | 3;
|
rhythmLevel: 1 | 2 | 3;
|
||||||
scaleCoverage: 'one-octave' | 'two-octave' | 'position';
|
scaleCoverage: 'one-octave' | 'two-octave' | 'position';
|
||||||
scaleDirection: 'up' | 'down' | 'updown';
|
scaleDirection: 'up' | 'down' | 'updown';
|
||||||
|
scaleRhythm: 'quarters' | 'eighths';
|
||||||
/** Arpeggio settings (material = 'arpeggio'). */
|
/** Arpeggio settings (material = 'arpeggio'). */
|
||||||
arpeggioDegree: string; // ArpeggioDegree
|
arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start)
|
||||||
|
/** Sequence mode: which degrees may appear (empty = all 7). */
|
||||||
|
arpeggioChordSelection: string[];
|
||||||
arpeggioPattern: 'up' | 'down' | 'updown' | '1235';
|
arpeggioPattern: 'up' | 'down' | 'updown' | '1235';
|
||||||
arpeggioCoverage: 'one-octave' | 'two-octave';
|
arpeggioCoverage: 'one-octave' | 'two-octave';
|
||||||
/** Sequence mode: bars per exercise (1 = single chord). */
|
/** Sequence mode: bars per exercise (1 = single chord). */
|
||||||
@@ -90,7 +93,9 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
|
|||||||
rhythmLevel: 1,
|
rhythmLevel: 1,
|
||||||
scaleCoverage: 'one-octave',
|
scaleCoverage: 'one-octave',
|
||||||
scaleDirection: 'updown',
|
scaleDirection: 'updown',
|
||||||
|
scaleRhythm: 'quarters',
|
||||||
arpeggioDegree: 'I',
|
arpeggioDegree: 'I',
|
||||||
|
arpeggioChordSelection: [],
|
||||||
arpeggioPattern: 'up',
|
arpeggioPattern: 'up',
|
||||||
arpeggioCoverage: 'one-octave',
|
arpeggioCoverage: 'one-octave',
|
||||||
arpeggioBars: 1,
|
arpeggioBars: 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user