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:
OpenClaw
2026-09-14 19:46:23 +00:00
parent 9dc95e3787
commit ffb94cc93f
6 changed files with 93 additions and 97 deletions

View File

@@ -40,6 +40,9 @@ export interface ArpeggioConfig {
readonly bars?: number;
readonly progression?: ArpeggioProgression;
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). */
readonly seed?: number;
}
@@ -215,6 +218,9 @@ export function generateArpeggio(
// Single-bar mode: the chosen degree for all bars (bars is 1).
// Sequence mode: one chord per bar, chosen by progression rule.
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) {
degreeIndices.push(DEGREE_INDEX[config.degree]);
} else {
@@ -222,13 +228,19 @@ export function generateArpeggio(
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
for (let b = 0; b < bars; b++) {
if (b === 0) {
// Start on the user-chosen degree (or tonic default)
degreeIndices.push(DEGREE_INDEX[config.degree]);
// First bar: a random chord FROM THE SELECTION — fixed starts
// repeated the exercise identically every time.
degreeIndices.push(pool7.includes(DEGREE_INDEX[config.degree]) && rng() < 0.34
? DEGREE_INDEX[config.degree]
: pick(pool7));
continue;
}
const prev = degreeIndices[b - 1];
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') {
// Simple tonal function rules (major-oriented; mirrored for
// minor by degree relationships below):
@@ -248,11 +260,13 @@ export function generateArpeggio(
5: [1, 3],
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 {
// random: any diatonic degree, avoid immediate repetition
const candidates = [0, 1, 2, 3, 4, 5, 6].filter(d => d !== prev);
degreeIndices.push(pick(candidates));
// random: any selected degree, avoid immediate repetition
const candidates = pool7.filter(d => d !== prev);
degreeIndices.push(pick(candidates.length > 0 ? candidates : pool7));
}
}
}

View File

@@ -12,6 +12,7 @@ import { keyFor, scalePitches, type KeyContext, type ModeId } from './scales';
export type DrillDirection = 'up' | 'down' | 'updown';
export type DrillCoverage = 'one-octave' | 'two-octave' | 'position';
export type DrillRhythm = 'quarters' | 'eighths';
export interface DrillConfig {
readonly keyTonic: string;
@@ -19,6 +20,8 @@ export interface DrillConfig {
readonly direction: DrillDirection;
readonly coverage: DrillCoverage;
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. */
@@ -87,14 +90,15 @@ export function generateScaleDrill(
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;
if (path.length * PPQ > 8 * full) {
return { ok: false, error: 'This drill is longer than 8 bars — reduce the coverage or direction.' };
if (path.length * noteDur > 8 * full) {
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 lastDuration = PPQ + (totalTicks - path.length * PPQ);
const lastDuration = noteDur + (totalTicks - path.length * noteDur);
const events: ScoreEvent[] = [];
let cursor = 0;
@@ -104,7 +108,7 @@ export function generateScaleDrill(
return found ? found.pitch : sp[0]?.pitch ?? null;
};
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]);
if (!pitch) return { ok: false, error: 'Internal error spelling drill pitch.' };
events.push({ id: `s${i}`, startTick: cursor, durationTicks: dur, pitch });
@@ -113,7 +117,7 @@ export function generateScaleDrill(
const score: Score = {
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}`,
meter: config.meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },