mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
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:
@@ -19,7 +19,9 @@ import { audioEngine } from '../audio/AudioEngine';
|
||||
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange';
|
||||
import { generateMelody } from '../music/melodyGenerator';
|
||||
import { generateScaleDrill } from '../music/scaleDrills';
|
||||
import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator';
|
||||
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 { getNoteDetails } from '../music/NoteUtils';
|
||||
import { EXERCISES, loadExercise } from '../exercises/library';
|
||||
@@ -200,6 +202,16 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
meter: { numerator: phrase.meterNumerator, denominator: 4 },
|
||||
}, 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') {
|
||||
const id = phrase.libraryId || EXERCISES[0]?.id;
|
||||
if (!id) return { ok: false, error: 'No exercises available.' };
|
||||
@@ -212,6 +224,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
return null;
|
||||
}, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator,
|
||||
phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.libraryId,
|
||||
phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage,
|
||||
melodySeed, pool, importedScore]);
|
||||
|
||||
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'] })}>
|
||||
<option value="melody">Melodies (generated)</option>
|
||||
<option value="scale">Scale drills</option>
|
||||
<option value="arpeggio">Arpeggios (generated)</option>
|
||||
<option value="library">Exercises</option>
|
||||
<option value="import">Imported file</option>
|
||||
</select>
|
||||
</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' }}>
|
||||
Key/mode: use the "Target key" controls below the sheet — shared across modes.
|
||||
</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 & 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' && (
|
||||
<label>Exercise
|
||||
<select value={phrase.libraryId || EXERCISES[0].id} onChange={e => setPhrase({ libraryId: e.target.value, startBar: 1 })}>
|
||||
|
||||
91
src/music/arpeggioGenerator.test.ts
Normal file
91
src/music/arpeggioGenerator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
181
src/music/arpeggioGenerator.ts
Normal file
181
src/music/arpeggioGenerator.ts
Normal 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 };
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export interface RhythmSettings {
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export type PhraseMaterial = 'melody' | 'scale' | 'library' | 'import';
|
||||
export type PhraseMaterial = 'melody' | 'scale' | 'arpeggio' | 'library' | 'import';
|
||||
export type PhrasePace = 'step' | 'tempo';
|
||||
|
||||
export interface PhraseSettings {
|
||||
@@ -53,6 +53,10 @@ export interface PhraseSettings {
|
||||
rhythmLevel: 1 | 2 | 3;
|
||||
scaleCoverage: 'one-octave' | 'two-octave' | 'position';
|
||||
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. */
|
||||
fretWindowEnabled: boolean;
|
||||
fretMin: number;
|
||||
@@ -79,6 +83,9 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
|
||||
rhythmLevel: 1,
|
||||
scaleCoverage: 'one-octave',
|
||||
scaleDirection: 'updown',
|
||||
arpeggioDegree: 'I',
|
||||
arpeggioPattern: 'up',
|
||||
arpeggioCoverage: 'one-octave',
|
||||
fretWindowEnabled: false,
|
||||
fretMin: 0,
|
||||
fretMax: 4,
|
||||
|
||||
Reference in New Issue
Block a user