Arpeggio material for Phrase Mode (variant 1)

- music/arpeggioGenerator.ts: diatonic triads (I-vii*) built from the
  selected key/mode via the shared degree spelling; patterns up / down /
  up-down / 1-2-3-5; one or two octaves (cycle ends on the octave; two-
  octave path continues from the turning root). Quarter-note rhythm with
  final note extended to fill the bar; title is labeled 'Arpeggio -
  <tonic> <degree> (pattern, coverage)'.
- Settings: PhraseMaterial 'arpeggio' + arpeggioDegree/Pattern/Coverage
  (defaults I / up / one-octave).
- PhraseTrainer: material option 'Arpeggios (generated)' + Chord/Pattern/
  Coverage/Meter selects; key/mode reuse the shared Target key controls.
- Tests: 9 arpeggio cases (170 total).
This commit is contained in:
OpenClaw
2026-09-14 17:10:40 +00:00
parent a965dde585
commit d76d24ab01
4 changed files with 327 additions and 2 deletions

View File

@@ -19,7 +19,9 @@ import { audioEngine } from '../audio/AudioEngine';
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange'; import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange';
import { generateMelody } from '../music/melodyGenerator'; import { generateMelody } from '../music/melodyGenerator';
import { generateScaleDrill } from '../music/scaleDrills'; import { generateScaleDrill } from '../music/scaleDrills';
import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator';
import { isMode, type ModeId } from '../music/scales'; import { isMode, type ModeId } from '../music/scales';
import { ARPEGGIO_DEGREE_LABELS } from '../music/arpeggioGenerator';
import { scoreEvents, type Result, type Score, type ScoreEvent } from '../score/model'; import { scoreEvents, type Result, type Score, type ScoreEvent } from '../score/model';
import { getNoteDetails } from '../music/NoteUtils'; import { getNoteDetails } from '../music/NoteUtils';
import { EXERCISES, loadExercise } from '../exercises/library'; import { EXERCISES, loadExercise } from '../exercises/library';
@@ -200,6 +202,16 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
meter: { numerator: phrase.meterNumerator, denominator: 4 }, meter: { numerator: phrase.meterNumerator, denominator: 4 },
}, pool); }, pool);
} }
if (phrase.material === 'arpeggio') {
return generateArpeggio({
keyTonic: phrase.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId,
degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I',
pattern: phrase.arpeggioPattern,
coverage: phrase.arpeggioCoverage,
meter: { numerator: phrase.meterNumerator, denominator: 4 },
}, pool);
}
if (phrase.material === 'library') { if (phrase.material === 'library') {
const id = phrase.libraryId || EXERCISES[0]?.id; const id = phrase.libraryId || EXERCISES[0]?.id;
if (!id) return { ok: false, error: 'No exercises available.' }; if (!id) return { ok: false, error: 'No exercises available.' };
@@ -212,6 +224,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.libraryId,
phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage,
melodySeed, pool, importedScore]); melodySeed, pool, importedScore]);
const fullScore: Result<Score> | null = useMemo(() => { const fullScore: Result<Score> | null = useMemo(() => {
@@ -546,12 +559,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
<select value={phrase.material} onChange={e => setPhrase({ material: e.target.value as PhraseSettings['material'] })}> <select value={phrase.material} onChange={e => setPhrase({ material: e.target.value as PhraseSettings['material'] })}>
<option value="melody">Melodies (generated)</option> <option value="melody">Melodies (generated)</option>
<option value="scale">Scale drills</option> <option value="scale">Scale drills</option>
<option value="arpeggio">Arpeggios (generated)</option>
<option value="library">Exercises</option> <option value="library">Exercises</option>
<option value="import">Imported file</option> <option value="import">Imported file</option>
</select> </select>
</label> </label>
{(phrase.material === 'melody' || phrase.material === 'scale') && ( {(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}> <span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Key/mode: use the "Target key" controls below the sheet — shared across modes. Key/mode: use the "Target key" controls below the sheet — shared across modes.
</span> </span>
@@ -605,6 +619,38 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</> </>
)} )}
{phrase.material === 'arpeggio' && (
<>
<label>Chord
<select value={phrase.arpeggioDegree} onChange={e => setPhrase({ arpeggioDegree: e.target.value })}>
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => (
<option key={d} value={d}>{ARPEGGIO_DEGREE_LABELS[d]}</option>
))}
</select>
</label>
<label>Pattern
<select value={phrase.arpeggioPattern} onChange={e => setPhrase({ arpeggioPattern: e.target.value as PhraseSettings['arpeggioPattern'] })}>
<option value="up">Up</option>
<option value="down">Down</option>
<option value="updown">Up &amp; down</option>
<option value="1235">1-2-3-5</option>
</select>
</label>
<label>Coverage
<select value={phrase.arpeggioCoverage} onChange={e => setPhrase({ arpeggioCoverage: e.target.value as PhraseSettings['arpeggioCoverage'] })}>
<option value="one-octave">One octave</option>
<option value="two-octave">Two octaves</option>
</select>
</label>
<label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Number(e.target.value) === 3 ? 3 : 4 })}>
<option value={4}>4/4</option>
<option value={3}>3/4</option>
</select>
</label>
</>
)}
{phrase.material === 'library' && ( {phrase.material === 'library' && (
<label>Exercise <label>Exercise
<select value={phrase.libraryId || EXERCISES[0].id} onChange={e => setPhrase({ libraryId: e.target.value, startBar: 1 })}> <select value={phrase.libraryId || EXERCISES[0].id} onChange={e => setPhrase({ libraryId: e.target.value, startBar: 1 })}>

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { generateArpeggio } from './arpeggioGenerator';
import { scoreEvents, validateScore } from '../score/model';
const strings = [40, 45, 50, 55, 59, 64];
const pool: number[] = [];
for (const open of strings) for (let f = 0; f <= 12; f++) pool.push(open + f);
const base = {
keyTonic: 'C',
keyMode: 'major' as const,
meter: { numerator: 4 as const, denominator: 4 as const },
};
describe('generateArpeggio', () => {
it('builds a C major arpeggio (up, one octave) from the pool', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value);
// C-E-G-C: 4 notes, quarter each (plus final extension)
const midis = ev.map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60]);
expect(validateScore(r.value)).toEqual([]);
expect(r.value.title).toContain('Arpeggio');
expect(r.value.title).toContain('C I');
});
it('down pattern reverses the ascending cycle', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'down', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi);
expect(midis).toEqual([60, 55, 52, 48]);
});
it('updown returns to the starting root without duplicating the top', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'updown', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi);
// ascend C-E-G-C then descend back to C; only one C5 (turning point)
expect(midis).toEqual([48, 52, 55, 60, 55, 52, 48]);
expect(midis.filter(m => m === 60)).toHaveLength(1);
});
it('1235 pattern ends on the octave of the root', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: '1235', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60]);
});
it('two-octave coverage spans 12 semitones beyond the first cycle', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'two-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60, 64, 67, 72]);
});
it('spells the dominant triad G-B-D in C major', () => {
const r = generateArpeggio({ ...base, degree: 'V', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const pcs = scoreEvents(r.value).map(e => e.pitch!.midi % 12);
expect(pcs).toEqual([7, 11, 2, 7]); // G B D G
});
it('spells the leading-tone dim triad B-D-F in C major', () => {
const r = generateArpeggio({ ...base, degree: 'vii0', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const pcs = scoreEvents(r.value).map(e => e.pitch!.midi % 12);
expect(pcs).toEqual([11, 2, 5, 11]); // B D F B
});
it('fails cleanly when the pool cannot fit a full triad cycle', () => {
// Only frets 0-1 on all strings: no complete C-E-G cycle with octave
const tiny: number[] = [];
for (const open of strings) for (let f = 0; f <= 1; f++) tiny.push(open + f);
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave' }, tiny);
expect(r.ok).toBe(false);
});
it('is deterministic for identical configs', () => {
const a = generateArpeggio({ ...base, degree: 'IV', pattern: 'updown', coverage: 'one-octave' }, pool);
const b = generateArpeggio({ ...base, degree: 'IV', pattern: 'updown', coverage: 'one-octave' }, pool);
expect(a).toEqual(b);
});
});

View File

@@ -0,0 +1,181 @@
/**
* Arpeggio generator (Phrase Mode material).
*
* Diatonic triads (I, ii, iii, IV, V, vi, vii°) built from the selected
* key/mode; each practice run arpeggiates ONE chord — the user picks the
* degree, the pattern (up / down / up-down / 1-2-3-5) and the coverage
* (one or two octaves). Chord tones are spelled via the key's degree
* spelling (same machinery as melody/scale generators), so spelling and
* validation match the Phrase Mode invariants exactly.
*/
import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model';
import { keyFor, type KeyContext, type ModeId } from './scales';
import { STEP_LETTERS, LETTER_PC, type StepLetter } from '../score/model';
export type ArpeggioDegree = 'I' | 'ii' | 'iii' | 'IV' | 'V' | 'vi' | 'vii0';
export type ArpeggioPattern = 'up' | 'down' | 'updown' | '1235';
export type ArpeggioCoverage = 'one-octave' | 'two-octave';
export interface ArpeggioConfig {
readonly keyTonic: string;
readonly keyMode: ModeId;
readonly degree: ArpeggioDegree;
readonly pattern: ArpeggioPattern;
readonly coverage: ArpeggioCoverage;
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
}
/** Degree index (0-based) into the scale for each arpeggio degree label. */
const DEGREE_INDEX: Record<ArpeggioDegree, number> = {
I: 0, ii: 1, iii: 2, IV: 3, V: 4, vi: 5, 'vii0': 6,
};
export const ARPEGGIO_DEGREE_LABELS: Record<ArpeggioDegree, string> = {
I: 'I', ii: 'ii', iii: 'iii', IV: 'IV', V: 'V', vi: 'vi', 'vii0': 'vii°',
};
/** pc of a spelled degree (step letter + alter). */
const degreePc = (d: { step: StepLetter; alter: number }): number =>
(LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12;
/**
* Chord-tone midi notes of the diatonic triad on `degreeIndex` (0-based),
* intersected with the playable pool, ascending.
*/
function chordTonesInPool(key: KeyContext, degreeIndex: number, pool: number[]): number[] {
const steps = [0, 2, 4].map(offset => (degreeIndex + offset) % 7);
const pcs = new Set(steps.map(i => degreePc(key.degrees[i])));
return pool.filter(midi => pcs.has(((midi % 12) + 12) % 12));
}
/** Spelling (step, alter) for a midi note in the key — via scale degrees. */
function spellInKey(key: KeyContext, midi: number): { step: StepLetter; alter: number; octave: number } | null {
const pc = ((midi % 12) + 12) % 12;
const d = key.degrees.find(deg => degreePc(deg) === pc);
if (!d) return null;
const octave = (midi - pc) / 12 - 1;
return { step: d.step, alter: d.alter, octave };
}
/**
* Build the arpeggio path: chord tones ascending from the lowest available
* root (degree tone) that allows a full one/two-octave cycle in the pool.
* Pattern up/down/updown/1235 is applied to one octave cycle.
*/
function buildPath(key: KeyContext, degreeIndex: number, pool: number[], coverage: ArpeggioCoverage, pattern: ArpeggioPattern): number[] | null {
const tones = chordTonesInPool(key, degreeIndex, pool);
if (tones.length < 3) return null; // need at least one full triad
const rootPc = degreePc(key.degrees[degreeIndex]);
const octaveCycle = (startMidi: number): number[] => {
// chord tones >= startMidi within one octave, INCLUDING the octave
// of the root (an arpeggio cycle conventionally ends on the octave)
const cycle: number[] = [];
for (const midi of tones) {
if (midi >= startMidi && midi <= startMidi + 12) cycle.push(midi);
}
if (!cycle.includes(startMidi + 12) && pool.includes(startMidi + 12)) {
cycle.push(startMidi + 12);
}
return cycle.sort((a, b) => a - b);
};
const cycles: number[][] = [];
// candidate roots: chord-tone midis with pc == rootPc, ascending
const roots = tones.filter(m => ((m % 12) + 12) % 12 === rootPc);
const octavesWanted = coverage === 'one-octave' ? 1 : 2;
for (const root of roots) {
const first = octaveCycle(root);
if (first.length < 3 || first[0] !== root) continue;
// second octave: tones one octave above `root`
const second = first.map(m => m + 12).filter(m => pool.includes(m));
if (octavesWanted === 2 && second.length < 4) continue;
cycles.push(first);
break; // lowest fitting root
}
if (cycles.length === 0) return null;
const base = cycles[0];
const up = octavesWanted === 2
// base already ends on the octave of the root — the second octave
// continues from there (drop the duplicated turning root)
? [...base, ...base.map(m => m + 12).slice(1)]
: base;
switch (pattern) {
case 'up': return up;
case 'down': return [...up].reverse();
case 'updown': {
// ascend then descend, dropping the duplicated turning note
const desc = [...up].reverse().slice(1);
return [...up, ...desc];
}
case '1235': {
// root, third, fifth, octave (of the root) — repeated per octave
const path: number[] = [];
for (let oct = 0; oct < octavesWanted; oct++) {
path.push(base[0] + 12 * oct);
path.push(base[1] + 12 * oct);
path.push(base[2] + 12 * oct);
path.push(base[0] + 12 * (oct + 1));
}
return path;
}
}
}
export function generateArpeggio(
config: ArpeggioConfig,
eligiblePitches: number[],
): Result<Score> {
const key = keyFor(config.keyTonic, config.keyMode);
const pool = Array.from(new Set(eligiblePitches.filter(m => Number.isInteger(m)))).sort((a, b) => a - b);
if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' };
const degreeIndex = DEGREE_INDEX[config.degree];
const path = buildPath(key, degreeIndex, pool, config.coverage, config.pattern);
if (!path || path.length === 0) {
return {
ok: false,
error: 'The complete arpeggio does not fit the playable range — widen the range (strings/fret window) or pick another degree.',
};
}
// ---- Quarter-note rhythm, final note extended to fill its bar ---------
const full = config.meter.numerator * PPQ;
if (path.length * PPQ > 8 * full) {
return { ok: false, error: 'This arpeggio is longer than 8 bars — reduce the coverage.' };
}
const barCount = Math.max(1, Math.ceil((path.length * PPQ) / full));
const totalTicks = barCount * full;
const lastDuration = PPQ + (totalTicks - path.length * PPQ);
const events: ScoreEvent[] = [];
let cursor = 0;
for (let i = 0; i < path.length; i++) {
const dur = i === path.length - 1 ? lastDuration : PPQ;
const spelled = spellInKey(key, path[i]);
if (!spelled) return { ok: false, error: 'Internal error spelling arpeggio pitch.' };
events.push({ id: `a${i}`, startTick: cursor, durationTicks: dur, pitch: { midi: path[i], ...spelled } });
cursor += dur;
}
const chordName = `${key.tonic} ${ARPEGGIO_DEGREE_LABELS[config.degree]}`;
const patternLabel = { up: 'up', down: 'down', updown: 'up & down', '1235': '1-2-3-5' }[config.pattern];
const score: Score = {
version: 1,
id: `arpeggio-${config.keyTonic}-${config.keyMode}-${config.degree}-${config.pattern}-${config.coverage}-${config.meter.numerator}4`,
title: `Arpeggio — ${chordName} (${patternLabel}, ${config.coverage})`,
meter: config.meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
measures: Array.from({ length: barCount }, (_, i) => ({
number: i + 1,
startTick: i * full,
durationTicks: full,
})),
voices: [{ id: 'melody', events }],
};
const problems = validateScore(score);
if (problems.length > 0) return { ok: false, error: `Internal arpeggio error: ${problems[0]}` };
return { ok: true, value: score };
}

View File

@@ -39,7 +39,7 @@ export interface RhythmSettings {
volume: number; volume: number;
} }
export type PhraseMaterial = 'melody' | 'scale' | 'library' | 'import'; export type PhraseMaterial = 'melody' | 'scale' | 'arpeggio' | 'library' | 'import';
export type PhrasePace = 'step' | 'tempo'; export type PhrasePace = 'step' | 'tempo';
export interface PhraseSettings { export interface PhraseSettings {
@@ -53,6 +53,10 @@ 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';
/** Arpeggio settings (material = 'arpeggio'). */
arpeggioDegree: string; // ArpeggioDegree
arpeggioPattern: 'up' | 'down' | 'updown' | '1235';
arpeggioCoverage: 'one-octave' | 'two-octave';
/** Phrase-local fret window (guitar/bass) — overrides the note set. */ /** Phrase-local fret window (guitar/bass) — overrides the note set. */
fretWindowEnabled: boolean; fretWindowEnabled: boolean;
fretMin: number; fretMin: number;
@@ -79,6 +83,9 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
rhythmLevel: 1, rhythmLevel: 1,
scaleCoverage: 'one-octave', scaleCoverage: 'one-octave',
scaleDirection: 'updown', scaleDirection: 'updown',
arpeggioDegree: 'I',
arpeggioPattern: 'up',
arpeggioCoverage: 'one-octave',
fretWindowEnabled: false, fretWindowEnabled: false,
fretMin: 0, fretMin: 0,
fretMax: 4, fretMax: 4,