From aebed68127759addfde1c6b88cf4218635ec7871 Mon Sep 17 00:00:00 2001 From: OpenClaw Date: Tue, 15 Sep 2026 07:17:36 +0000 Subject: [PATCH] Arpeggio random pattern option + docs update --- docs/PHRASE_DELIVERY.md | 32 +++++++++++++++++++++++++++++ src/components/PhraseTrainer.tsx | 1 + src/music/arpeggioGenerator.test.ts | 23 +++++++++++++++++++++ src/music/arpeggioGenerator.ts | 20 +++++++++++++++--- src/types/SettingsTypes.ts | 2 +- 5 files changed, 74 insertions(+), 4 deletions(-) diff --git a/docs/PHRASE_DELIVERY.md b/docs/PHRASE_DELIVERY.md index d58ab4a..99223ec 100644 --- a/docs/PHRASE_DELIVERY.md +++ b/docs/PHRASE_DELIVERY.md @@ -99,3 +99,35 @@ Implementation of [PHRASE_MODE_PLAN.md](../PHRASE_MODE_PLAN.md) and - MIDI adapter, motif-based generation, persisted phrase setup, extra repertoire, per-note timing scoring — all explicitly deferred in the plan. + +## Feedback round 4 (2026-09-15): always-eighths arpeggio/Giuliani + custom/random patterns + +**Generator redesign (Phrase Mode materials `arpeggio` + `giuliani`):** + +- **Always eighth notes** (240 ticks/note). The quarter-note fallback and + stretch-the-last-note logic were removed — the latter produced + non-renderable 2880-tick durations (two-octave arpeggio in 6/8) that + crashed the VexFlow renderer (white screen). +- **Auto-derived meter** (`autoArpeggioMeter`): 3/4 vs 4/4 chosen by the + smallest rest remainder for the path length (fewer bars as tiebreaker). + Giuliani meter derives from figure length (3 voices → 3/4, 4 → 4/4, + 5 → 4/4 + rest fill, 6 → 3/4). The Meter and Rhythm selects were removed + from both setups; leftover bar space is filled with rests built from + renderable durations only. +- **Custom arpeggio pattern**: new `custom` pattern + text field; digits map + 1=root, 2=third, 3=fifth, 4=octave, 5=fifth above the octave; invalid + input fails with an actionable error. +- **Random arpeggio pattern**: new `random` pattern — one broken-chord + figure is chosen deterministically per seed, so every auto-continued run + gets a fresh figure while Retry stays reproducible. +- **Two-octave coverage** doubles the path; the auto-derived meter adjusts + (e.g. updown two-octave = 13 notes → 4/4 with a rest fill). +- **Giuliani**: `pimami` figure corrected to 6 voices; new figures + `pimai`, `pmia`, `pmim`; new **alternating bass** option (even bars voice + the fifth as the bass — the original studies' thumb-jump feel). +- **Renderer**: `minWidthPerBar` for sixteenths raised 420 → 500 px + (mobile clipping). + +**Verification:** 193 unit tests green (`npx vitest run`), `tsc --noEmit` +clean, production build + deploy to `smt-beta`. A pattern×degree×coverage +sweep test guarantees every generated duration stays in the renderable set. diff --git a/src/components/PhraseTrainer.tsx b/src/components/PhraseTrainer.tsx index aef5be7..79f81e5 100644 --- a/src/components/PhraseTrainer.tsx +++ b/src/components/PhraseTrainer.tsx @@ -652,6 +652,7 @@ export const PhraseTrainer = forwardRef( + diff --git a/src/music/arpeggioGenerator.test.ts b/src/music/arpeggioGenerator.test.ts index 8ae4cd5..88c4928 100644 --- a/src/music/arpeggioGenerator.test.ts +++ b/src/music/arpeggioGenerator.test.ts @@ -197,6 +197,29 @@ describe('generateArpeggio — always-eighths + custom pattern', () => { expect(bad.ok).toBe(false); }); + it('random pattern resolves to a valid broken-chord figure per seed', () => { + const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920]; + const seen = new Set(); + for (let seed = 0; seed < 30; seed++) { + const r = generateArpeggio({ ...base, degree: 'I', pattern: 'random', coverage: 'one-octave', seed }, pool); + expect(r.ok).toBe(true); + if (!r.ok) continue; + for (const e of scoreEvents(r.value)) expect(RENDERABLE).toContain(e.durationTicks); + expect(validateScore(r.value)).toEqual([]); + // The title reveals the resolved figure label + const match = r.value.title.match(/\(([^)]+)\,/); + if (match) seen.add(match[1]); + } + // Across seeds more than one distinct figure should appear + expect(seen.size).toBeGreaterThan(1); + }); + + it('random pattern is deterministic per seed', () => { + const a = generateArpeggio({ ...base, degree: 'I', pattern: 'random', coverage: 'two-octave', seed: 42 }, pool); + const b = generateArpeggio({ ...base, degree: 'I', pattern: 'random', coverage: 'two-octave', seed: 42 }, pool); + expect(JSON.stringify(a)).toBe(JSON.stringify(b)); + }); + it('sweep: every pattern × degree × coverage produces only renderable ticks', () => { const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920]; const patterns = ['up', 'down', 'updown', '1235', '1321', '1325', '1535', '12353', '121321', '1353', '15453', '132532'] as const; diff --git a/src/music/arpeggioGenerator.ts b/src/music/arpeggioGenerator.ts index a9a12d3..4c58d1c 100644 --- a/src/music/arpeggioGenerator.ts +++ b/src/music/arpeggioGenerator.ts @@ -29,7 +29,8 @@ export type ArpeggioPattern = | '1353' // root-third-fifth-third | '15453' // root-octave-fifth-octave-fifth-octave | '132532' // long broken-chord wave - | 'custom'; // user-defined pattern from customPattern digits + | 'custom' // user-defined pattern from customPattern digits + | 'random'; // one broken-chord figure, chosen randomly per exercise (seed) export type ArpeggioCoverage = 'one-octave' | 'two-octave'; /** How the chord per bar is chosen in sequence mode. */ export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle'; @@ -73,6 +74,7 @@ export const PATTERN_LABELS: Record = { '15453': '1-5-4-5-3-5', '132532': '1-3-2-5-3-2', 'custom': 'Custom…', + 'random': 'Random', }; /** @@ -373,6 +375,18 @@ function pushRestFill(events: ScoreEvent[], fromTick: number, untilTick: number) } } +/** + * Resolve the effective broken-chord figure for this exercise. 'random' + * picks one deterministic figure per seed so every auto-continued run gets + * a fresh broken-chord pattern while remaining reproducible. + */ +function resolveRandomPattern(pattern: ArpeggioPattern, seed: number): ArpeggioPattern { + if (pattern !== 'random') return pattern; + const figures = Object.keys(BROKEN_FIGURES); + const rng = makeRng(seed * 7919 + 13); + return figures[Math.floor(rng() * figures.length)] as ArpeggioPattern; +} + export function generateArpeggio( config: ArpeggioConfig, eligiblePitches: number[], @@ -382,7 +396,7 @@ export function generateArpeggio( if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' }; const bars = config.bars ?? 1; - const pattern = config.pattern; + const pattern = resolveRandomPattern(config.pattern, config.seed ?? 0); const coverage = config.coverage; // ---- Chord sequence ---------------------------------------------------- @@ -493,7 +507,7 @@ export function generateArpeggio( } const chordName = `${key.tonic} ${ARPEGGIO_DEGREE_LABELS[config.degree]}`; - const patternLabel = PATTERN_LABELS[config.pattern]; + const patternLabel = PATTERN_LABELS[pattern]; const totalMeasures = Math.max(1, Math.ceil(events.reduce((s, e) => Math.max(s, e.startTick + e.durationTicks), 0) / barLength)); const title = bars === 1 ? `Arpeggio — ${chordName} (${patternLabel}, ${coverage})` diff --git a/src/types/SettingsTypes.ts b/src/types/SettingsTypes.ts index 23f060f..d0b318a 100644 --- a/src/types/SettingsTypes.ts +++ b/src/types/SettingsTypes.ts @@ -61,7 +61,7 @@ export interface PhraseSettings { arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start) /** Sequence mode: which degrees may appear (empty = all 7). */ arpeggioChordSelection: string[]; - arpeggioPattern: 'up' | 'down' | 'updown' | '1235' | 'custom'; + arpeggioPattern: 'up' | 'down' | 'updown' | '1235' | 'custom' | 'random'; arpeggioCoverage: 'one-octave' | 'two-octave'; /** Sequence mode: bars per exercise (1 = single chord). */ arpeggioBars: number;