From d76d24ab016e755302a45b7080daba9777f100cc Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Mon, 14 Sep 2026 17:10:40 +0000 Subject: [PATCH] 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 - (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). --- src/components/PhraseTrainer.tsx | 48 +++++++- src/music/arpeggioGenerator.test.ts | 91 ++++++++++++++ src/music/arpeggioGenerator.ts | 181 ++++++++++++++++++++++++++++ src/types/SettingsTypes.ts | 9 +- 4 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 src/music/arpeggioGenerator.test.ts create mode 100644 src/music/arpeggioGenerator.ts diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index 3a97fb9..9f8ff30 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -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( 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( 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 | null = useMemo(() => { @@ -546,12 +559,13 @@ export const PhraseTrainer = forwardRef( - {(phrase.material === 'melody' || phrase.material === 'scale') && ( + {(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && ( Key/mode: use the "Target key" controls below the sheet — shared across modes. @@ -605,6 +619,38 @@ export const PhraseTrainer = forwardRef( )} + {phrase.material === 'arpeggio' && ( + <> + + + + + + )} + {phrase.material === 'library' && (