mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +01:00
Arpeggio random pattern option + docs update
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -652,6 +652,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
<option value="121321">1-2-1-3-2-1</option>
|
||||
<option value="15453">1-5-4-5-3-5</option>
|
||||
<option value="132532">1-3-2-5-3-2</option>
|
||||
<option value="random">Random</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
@@ -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<string>();
|
||||
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;
|
||||
|
||||
@@ -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<ArpeggioPattern, string> = {
|
||||
'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})`
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user