mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
- Sequence mode: bars (2/4/6/8) with one chord per bar, chosen by functional rules (I->IV/V/vi...), diatonic cycle, or random (no immediate repeats); deterministic via melody seed. Chord symbols per bar in the score model (chordSymbols) rendered above the staff via setSection. - Rhythm: quarters or eighths (cycling up/down through the pattern). - Single-chord mode unchanged (whole path, overflow bars if needed). - Auto-start: 'Start on first note (no count-in)' — step pace begins the run as soon as the mic detects the first note; no start press needed. - 'New' button re-rolls the progression (new seed). - Tests 174/174.
139 lines
6.5 KiB
TypeScript
139 lines
6.5 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
|
||
describe('generateArpeggio — sequence mode', () => {
|
||
it('generates one chord per bar with functional progression', () => {
|
||
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 4, progression: 'functional', seed: 7 }, pool);
|
||
expect(r.ok).toBe(true);
|
||
if (!r.ok) return;
|
||
expect(r.value.measures.length).toBe(4);
|
||
expect(r.value.chordSymbols?.length).toBe(4);
|
||
expect(r.value.chordSymbols?.[0]).toBe('C'); // starts on I
|
||
// bar boundaries: each bar starts on a chord root? (not enforced) — but ticks align
|
||
const ev = scoreEvents(r.value);
|
||
expect(validateScore(r.value)).toEqual([]);
|
||
// 4 beats per bar × 4 bars = 16 notes in quarters mode
|
||
expect(ev.length).toBe(16);
|
||
// deterministic
|
||
const r2 = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 4, progression: 'functional', seed: 7 }, pool);
|
||
expect(r2.ok && JSON.stringify(scoreEvents(r2.value)) === JSON.stringify(ev)).toBe(true);
|
||
});
|
||
|
||
it('diatonic cycle walks I ii iii IV V vi vii', () => {
|
||
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 7, progression: 'diatonic-cycle', seed: 1 }, pool);
|
||
expect(r.ok).toBe(true);
|
||
if (!r.ok) return;
|
||
const syms = r.value.chordSymbols!;
|
||
expect(syms).toEqual(['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim']);
|
||
});
|
||
|
||
it('eighths rhythm doubles notes per bar', () => {
|
||
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 2, progression: 'functional', rhythm: 'eighths', seed: 3 }, pool);
|
||
expect(r.ok).toBe(true);
|
||
if (!r.ok) return;
|
||
const ev = scoreEvents(r.value);
|
||
expect(ev.length).toBe(16); // 2 bars × 4 beats × 2
|
||
expect(ev[0].durationTicks).toBe(240);
|
||
expect(validateScore(r.value)).toEqual([]);
|
||
});
|
||
|
||
it('random avoids repeating the same chord back-to-back', () => {
|
||
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 6, progression: 'random', seed: 11 }, pool);
|
||
expect(r.ok).toBe(true);
|
||
if (!r.ok) return;
|
||
const syms = r.value.chordSymbols!;
|
||
for (let i = 1; i < syms.length; i++) {
|
||
expect(syms[i]).not.toBe(syms[i - 1]);
|
||
}
|
||
});
|
||
});
|