mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +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
|
- MIDI adapter, motif-based generation, persisted phrase setup, extra
|
||||||
repertoire, per-note timing scoring — all explicitly deferred in the plan.
|
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="121321">1-2-1-3-2-1</option>
|
||||||
<option value="15453">1-5-4-5-3-5</option>
|
<option value="15453">1-5-4-5-3-5</option>
|
||||||
<option value="132532">1-3-2-5-3-2</option>
|
<option value="132532">1-3-2-5-3-2</option>
|
||||||
|
<option value="random">Random</option>
|
||||||
<option value="custom">Custom…</option>
|
<option value="custom">Custom…</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -197,6 +197,29 @@ describe('generateArpeggio — always-eighths + custom pattern', () => {
|
|||||||
expect(bad.ok).toBe(false);
|
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', () => {
|
it('sweep: every pattern × degree × coverage produces only renderable ticks', () => {
|
||||||
const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920];
|
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;
|
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
|
| '1353' // root-third-fifth-third
|
||||||
| '15453' // root-octave-fifth-octave-fifth-octave
|
| '15453' // root-octave-fifth-octave-fifth-octave
|
||||||
| '132532' // long broken-chord wave
|
| '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';
|
export type ArpeggioCoverage = 'one-octave' | 'two-octave';
|
||||||
/** How the chord per bar is chosen in sequence mode. */
|
/** How the chord per bar is chosen in sequence mode. */
|
||||||
export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle';
|
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',
|
'15453': '1-5-4-5-3-5',
|
||||||
'132532': '1-3-2-5-3-2',
|
'132532': '1-3-2-5-3-2',
|
||||||
'custom': 'Custom…',
|
'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(
|
export function generateArpeggio(
|
||||||
config: ArpeggioConfig,
|
config: ArpeggioConfig,
|
||||||
eligiblePitches: number[],
|
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.' };
|
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 bars = config.bars ?? 1;
|
||||||
const pattern = config.pattern;
|
const pattern = resolveRandomPattern(config.pattern, config.seed ?? 0);
|
||||||
const coverage = config.coverage;
|
const coverage = config.coverage;
|
||||||
|
|
||||||
// ---- Chord sequence ----------------------------------------------------
|
// ---- Chord sequence ----------------------------------------------------
|
||||||
@@ -493,7 +507,7 @@ export function generateArpeggio(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chordName = `${key.tonic} ${ARPEGGIO_DEGREE_LABELS[config.degree]}`;
|
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 totalMeasures = Math.max(1, Math.ceil(events.reduce((s, e) => Math.max(s, e.startTick + e.durationTicks), 0) / barLength));
|
||||||
const title = bars === 1
|
const title = bars === 1
|
||||||
? `Arpeggio — ${chordName} (${patternLabel}, ${coverage})`
|
? `Arpeggio — ${chordName} (${patternLabel}, ${coverage})`
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export interface PhraseSettings {
|
|||||||
arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start)
|
arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start)
|
||||||
/** Sequence mode: which degrees may appear (empty = all 7). */
|
/** Sequence mode: which degrees may appear (empty = all 7). */
|
||||||
arpeggioChordSelection: string[];
|
arpeggioChordSelection: string[];
|
||||||
arpeggioPattern: 'up' | 'down' | 'updown' | '1235' | 'custom';
|
arpeggioPattern: 'up' | 'down' | 'updown' | '1235' | 'custom' | 'random';
|
||||||
arpeggioCoverage: 'one-octave' | 'two-octave';
|
arpeggioCoverage: 'one-octave' | 'two-octave';
|
||||||
/** Sequence mode: bars per exercise (1 = single chord). */
|
/** Sequence mode: bars per exercise (1 = single chord). */
|
||||||
arpeggioBars: number;
|
arpeggioBars: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user