mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Phrase mode P0: score model, scales/keys, playable range, melody + scale generators (tested)
This commit is contained in:
190
src/music/generators.test.ts
Normal file
190
src/music/generators.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { generateMelody } from './melodyGenerator';
|
||||
import { generateScaleDrill } from './scaleDrills';
|
||||
import { validateScore, scoreEvents, scoreFitsPool, PPQ } from '../score/model';
|
||||
import { keyFor } from './scales';
|
||||
|
||||
const GUITAR_FIRST_POS = [40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68];
|
||||
|
||||
describe('generateMelody — invariants over many seeds', () => {
|
||||
const configBase = { keyTonic: 'C', keyMode: 'major' as const, bars: 4, meter: { numerator: 4 as const, denominator: 4 as const }, rhythmLevel: 2 as const };
|
||||
|
||||
it('every seed yields a valid score in scale and pool', () => {
|
||||
for (let seed = 1; seed <= 100; seed++) {
|
||||
const res = generateMelody({ ...configBase, seed }, GUITAR_FIRST_POS);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) continue;
|
||||
const { score } = res.value;
|
||||
expect(validateScore(score)).toEqual([]);
|
||||
expect(scoreFitsPool(score, GUITAR_FIRST_POS)).toBe(true);
|
||||
const key = keyFor('C', 'major');
|
||||
for (const e of scoreEvents(score)) {
|
||||
if (!e.pitch) continue;
|
||||
expect(key.pitchClasses).toContain(((e.pitch.midi % 12) + 12) % 12);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('is deterministic under the same seed', () => {
|
||||
const a = generateMelody({ ...configBase, seed: 42 }, GUITAR_FIRST_POS);
|
||||
const b = generateMelody({ ...configBase, seed: 42 }, GUITAR_FIRST_POS);
|
||||
expect(a).toEqual(b);
|
||||
});
|
||||
|
||||
it('different seeds usually differ', () => {
|
||||
const a = generateMelody({ ...configBase, seed: 1 }, GUITAR_FIRST_POS);
|
||||
const b = generateMelody({ ...configBase, seed: 2 }, GUITAR_FIRST_POS);
|
||||
expect(a).not.toEqual(b);
|
||||
});
|
||||
|
||||
it('respects bar count and exact bar totals for 1..8 bars and both meters', () => {
|
||||
for (const numerator of [3, 4] as const) {
|
||||
for (const bars of [1, 3, 8]) {
|
||||
const res = generateMelody({ ...configBase, meter: { numerator, denominator: 4 }, bars, seed: 7 }, GUITAR_FIRST_POS);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) continue;
|
||||
const { score } = res.value;
|
||||
expect(score.measures).toHaveLength(bars);
|
||||
expect(validateScore(score)).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('final note lasts at least two quarter beats (soft, on normal pool)', () => {
|
||||
for (let seed = 1; seed <= 50; seed++) {
|
||||
const res = generateMelody({ ...configBase, seed }, GUITAR_FIRST_POS);
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
const evts = scoreEvents(res.value.score);
|
||||
expect(evts[evts.length - 1].durationTicks).toBeGreaterThanOrEqual(2 * PPQ);
|
||||
}
|
||||
});
|
||||
|
||||
it('ends on the tonic when the tonic is available (soft, majority of seeds)', () => {
|
||||
let tonicEndings = 0;
|
||||
for (let seed = 1; seed <= 50; seed++) {
|
||||
const res = generateMelody({ ...configBase, seed }, GUITAR_FIRST_POS);
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
const evts = scoreEvents(res.value.score);
|
||||
const last = evts[evts.length - 1].pitch;
|
||||
if (last && last.midi % 12 === 0) tonicEndings++;
|
||||
}
|
||||
expect(tonicEndings).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('rejects empty or single-pitch pools with actionable errors', () => {
|
||||
expect(generateMelody({ ...configBase, seed: 1 }, []).ok).toBe(false);
|
||||
expect(generateMelody({ ...configBase, seed: 1 }, [60]).ok).toBe(false);
|
||||
// Two identical entries count as one pitch.
|
||||
expect(generateMelody({ ...configBase, seed: 1 }, [60, 60]).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('generates for every supported key/mode combination', () => {
|
||||
const tonics = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'Db', 'Ab', 'Eb', 'Bb', 'F'];
|
||||
const modes = ['major', 'minor', 'dorian', 'phrygian', 'lydian', 'mixolydian', 'locrian', 'major-pentatonic', 'minor-pentatonic'] as const;
|
||||
for (const t of tonics) {
|
||||
for (const m of modes) {
|
||||
const key = keyFor(t, m);
|
||||
// Wide chromatic pool so every key is testable.
|
||||
const pool = Array.from({ length: 40 }, (_, i) => 48 + i).filter(midi =>
|
||||
key.pitchClasses.includes(midi % 12));
|
||||
if (pool.length < 2) continue;
|
||||
const res = generateMelody({ keyTonic: t, keyMode: m, bars: 2, meter: { numerator: 4, denominator: 4 }, rhythmLevel: 1, seed: 3 }, pool);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) continue;
|
||||
expect(validateScore(res.value.score)).toEqual([]);
|
||||
expect(scoreFitsPool(res.value.score, pool)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('survives a sparse pool (guitar high position) without escaping it', () => {
|
||||
// Standard tuning, frets 9-12: 4 pitches per string × 6 strings.
|
||||
const sparse = [49, 50, 51, 52, 54, 55, 56, 57, 59, 60, 61, 62, 64, 65, 66, 67, 69, 70, 71, 72, 74, 75, 76, 77];
|
||||
const inKey = sparse.filter(m => [0, 2, 4, 5, 7, 9, 11].includes(m % 12));
|
||||
for (let seed = 1; seed <= 50; seed++) {
|
||||
const res = generateMelody({ ...configBase, seed }, sparse);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) continue;
|
||||
expect(scoreFitsPool(res.value.score, sparse)).toBe(true);
|
||||
expect(validateScore(res.value.score)).toEqual([]);
|
||||
}
|
||||
expect(inKey.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects invalid bar counts', () => {
|
||||
expect(generateMelody({ ...configBase, bars: 0, seed: 1 }, GUITAR_FIRST_POS).ok).toBe(false);
|
||||
expect(generateMelody({ ...configBase, bars: 9, seed: 1 }, GUITAR_FIRST_POS).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateScaleDrill', () => {
|
||||
it('produces the expected C-major one-octave run in an eligible register', () => {
|
||||
const res = generateScaleDrill(
|
||||
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'one-octave', meter: { numerator: 4, denominator: 4 } },
|
||||
GUITAR_FIRST_POS,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
const evts = scoreEvents(res.value);
|
||||
// Lowest complete C-octave in first position: C3(48)? E on 2nd string 1st... 48..60 available?
|
||||
expect(evts.map(e => e.pitch!.midi)).toEqual([48, 50, 52, 53, 55, 57, 59, 60]);
|
||||
expect(evts.every(e => e.durationTicks === PPQ || e === evts[evts.length - 1])).toBe(true);
|
||||
expect(validateScore(res.value)).toEqual([]);
|
||||
});
|
||||
|
||||
it('up-and-down has a single turning tonic', () => {
|
||||
const res = generateScaleDrill(
|
||||
{ keyTonic: 'G', keyMode: 'major', direction: 'updown', coverage: 'one-octave', meter: { numerator: 3, denominator: 4 } },
|
||||
GUITAR_FIRST_POS,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
const midis = scoreEvents(res.value).map(e => e.pitch!.midi);
|
||||
// Lowest complete G octave in first position is G2 (43): up then down without repeating the top tonic
|
||||
expect(midis).toEqual([43, 45, 47, 48, 50, 52, 54, 55, 54, 52, 50, 48, 47, 45, 43]);
|
||||
expect(validateScore(res.value)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extends the final note to complete its bar', () => {
|
||||
const res = generateScaleDrill(
|
||||
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'one-octave', meter: { numerator: 4, denominator: 4 } },
|
||||
GUITAR_FIRST_POS,
|
||||
);
|
||||
if (!res.ok) throw new Error(res.error);
|
||||
const evts = scoreEvents(res.value);
|
||||
expect(evts[evts.length - 1].durationTicks).toBe(PPQ); // 8 notes → exactly 2 bars, no extension needed
|
||||
expect(validateScore(res.value)).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses an incomplete one-octave scale with an actionable error', () => {
|
||||
// C major but missing E (52): one octave C3→C4 impossible.
|
||||
const pool = GUITAR_FIRST_POS.filter(m => m !== 52);
|
||||
const res = generateScaleDrill(
|
||||
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'one-octave', meter: { numerator: 4, denominator: 4 } },
|
||||
pool,
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
if (!res.ok) expect(res.error).toMatch(/one-octave/i);
|
||||
});
|
||||
|
||||
it('refuses a two-octave drill when it does not fit', () => {
|
||||
const res = generateScaleDrill(
|
||||
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'two-octave', meter: { numerator: 4, denominator: 4 } },
|
||||
GUITAR_FIRST_POS,
|
||||
);
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('position drills traverse eligible notes in order', () => {
|
||||
const res = generateScaleDrill(
|
||||
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'position', meter: { numerator: 4, denominator: 4 } },
|
||||
GUITAR_FIRST_POS,
|
||||
);
|
||||
expect(res.ok).toBe(true);
|
||||
if (!res.ok) return;
|
||||
const midis = scoreEvents(res.value).map(e => e.pitch!.midi);
|
||||
const expected = GUITAR_FIRST_POS.filter(m => [0, 2, 4, 5, 7, 9, 11].includes(m % 12));
|
||||
expect(midis).toEqual(expected);
|
||||
expect(validateScore(res.value)).toEqual([]);
|
||||
});
|
||||
});
|
||||
215
src/music/melodyGenerator.ts
Normal file
215
src/music/melodyGenerator.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Seeded melody generator (Phrase Mode).
|
||||
*
|
||||
* Deterministic: all randomness flows through a supplied seed so Retry can
|
||||
* replay the identical phrase (the trainer never regenerates) and tests can
|
||||
* assert invariants over many seeds (contract C3).
|
||||
*
|
||||
* Hard invariants (tested): every pitch ∈ scale ∩ playable pool, exact bar
|
||||
* totals, valid spelling, determinism, bounded loops.
|
||||
* Musical preferences (steps, small contours, tonic endings) are SOFT — a
|
||||
* sparse fretboard pool can make them impossible, and the generator must
|
||||
* still terminate and stay in the pool rather than escape its constraints.
|
||||
*/
|
||||
import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model';
|
||||
import { keyFor, scalePitches, type ModeId } from './scales';
|
||||
|
||||
/** mulberry32 — small, fast, deterministic. */
|
||||
export function makeRng(seed: number): () => number {
|
||||
let a = seed >>> 0;
|
||||
return () => {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MelodyConfig {
|
||||
readonly keyTonic: string;
|
||||
readonly keyMode: ModeId;
|
||||
/** 1..8 */
|
||||
readonly bars: number;
|
||||
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
|
||||
/** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters). */
|
||||
readonly rhythmLevel: 1 | 2;
|
||||
readonly seed: number;
|
||||
}
|
||||
|
||||
// Rhythm templates in ticks (quarter = 480). Each template fills one full bar.
|
||||
const TEMPLATES_4_L1: number[][] = [
|
||||
[480, 480, 480, 480],
|
||||
[960, 960],
|
||||
[480, 480, 960],
|
||||
[960, 480, 480],
|
||||
[480, 960, 480],
|
||||
[1920],
|
||||
[1440, 480],
|
||||
];
|
||||
const TEMPLATES_4_L2: number[][] = [
|
||||
[480, 480, 240, 240, 480],
|
||||
[240, 240, 480, 480, 480],
|
||||
[480, 480, 480, 240, 240],
|
||||
[720, 720, 480],
|
||||
[720, 480, 240, 240, 240],
|
||||
];
|
||||
const TEMPLATES_3_L1: number[][] = [
|
||||
[480, 480, 480],
|
||||
[960, 480],
|
||||
[480, 960],
|
||||
[1440],
|
||||
];
|
||||
const TEMPLATES_3_L2: number[][] = [
|
||||
[240, 240, 480, 480],
|
||||
[480, 240, 240, 480],
|
||||
[720, 720],
|
||||
[720, 240, 240, 240],
|
||||
];
|
||||
|
||||
function templatesFor(meter: { numerator: 3 | 4 }, level: 1 | 2): number[][] {
|
||||
const base = meter.numerator === 4
|
||||
? (level === 1 ? TEMPLATES_4_L1 : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2])
|
||||
: (level === 1 ? TEMPLATES_3_L1 : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2]);
|
||||
return base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ);
|
||||
}
|
||||
|
||||
/** Motion weights by semitone distance (soft preference, contract C3). */
|
||||
function motionWeight(delta: number, afterLeap: boolean, repeats: number): number {
|
||||
const ad = Math.abs(delta);
|
||||
if (delta === 0) return repeats >= 2 ? 0 : 6; // never more than two in a row
|
||||
if (ad <= 2) return afterLeap ? 240 : 70; // steps (preferred, esp. after leaps)
|
||||
if (ad <= 4) return afterLeap ? 120 : 20; // thirds
|
||||
if (ad <= 7) return 8; // fourth/fifth
|
||||
if (ad <= 12) return 2; // sixth/octave
|
||||
return 0; // out of pool handled separately
|
||||
}
|
||||
|
||||
export function generateMelody(
|
||||
config: MelodyConfig,
|
||||
eligiblePitches: number[],
|
||||
): Result<{ score: Score; seed: number }> {
|
||||
const { bars, meter, rhythmLevel, seed } = config;
|
||||
if (!Number.isInteger(bars) || bars < 1 || bars > 8) {
|
||||
return { ok: false, error: 'Bars must be 1–8.' };
|
||||
}
|
||||
const key = keyFor(config.keyTonic, config.keyMode);
|
||||
|
||||
const pool = Array.from(new Set(eligiblePitches.filter(m => Number.isInteger(m)))).sort((a, b) => a - b);
|
||||
if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set or change the key.' };
|
||||
const scale = scalePitches(key, pool[0], pool[pool.length - 1]).filter(p => pool.includes(p.midi));
|
||||
if (scale.length < 2) {
|
||||
return { ok: false, error: 'The selected range/key combination leaves fewer than two usable pitches — widen the range or change the key.' };
|
||||
}
|
||||
|
||||
const templates = templatesFor(meter, rhythmLevel);
|
||||
const endingTemplates = templates.filter(t => t[t.length - 1] >= 960);
|
||||
const finalTemplates = endingTemplates.length > 0 ? endingTemplates : templates;
|
||||
|
||||
const rng = makeRng(seed);
|
||||
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
|
||||
|
||||
// ---- Build the rhythm (bar by bar) -------------------------------------
|
||||
const rhythm: number[] = [];
|
||||
for (let bar = 0; bar < bars; bar++) {
|
||||
const t = bar === bars - 1 ? finalTemplates : templates;
|
||||
rhythm.push(...pick(t));
|
||||
}
|
||||
|
||||
// ---- Build the pitches -------------------------------------------------
|
||||
// Weighted random walk over the eligible scale notes with soft musical
|
||||
// preferences. Bounded: exactly one decision per rhythm slot.
|
||||
const tonicPc = key.pitchClasses[0];
|
||||
const thirdPc = key.pitchClasses[2];
|
||||
const fifthPc = key.pitchClasses[4];
|
||||
|
||||
const events: ScoreEvent[] = [];
|
||||
let idx = Math.floor(rng() * scale.length); // start anywhere; endpoints preferred next
|
||||
// Preferred start: tonic, else 3rd/5th.
|
||||
const startCandidates = scale
|
||||
.map((p, i) => ({ p, i }))
|
||||
.filter(({ p }) => p.pitch.midi % 12 === tonicPc);
|
||||
if (startCandidates.length > 0) idx = pick(startCandidates).i;
|
||||
else {
|
||||
const alt = scale.map((p, i) => ({ p, i })).filter(({ p }) => [thirdPc, fifthPc].includes(p.pitch.midi % 12));
|
||||
if (alt.length > 0) idx = pick(alt).i;
|
||||
}
|
||||
|
||||
let lastWasLeap = false; // previous motion was a leap ≥ 5 semitones
|
||||
let leapFromMidi = 0; // origin pitch of that leap (step-back target)
|
||||
let repeats = 0;
|
||||
let cursor = 0;
|
||||
for (let slot = 0; slot < rhythm.length; slot++) {
|
||||
const dur = rhythm[slot];
|
||||
const isFinal = slot === rhythm.length - 1;
|
||||
const cur = scale[idx];
|
||||
|
||||
let chosen = idx;
|
||||
if (isFinal) {
|
||||
// Soft: end on tonic, else 3rd/5th, else nearest to current.
|
||||
const finals = scale.map((p, i) => ({ p, i })).filter(({ p }) => p.pitch.midi % 12 === tonicPc);
|
||||
const alt = scale.map((p, i) => ({ p, i })).filter(({ p }) => [thirdPc, fifthPc].includes(p.pitch.midi % 12));
|
||||
const cand = finals.length > 0 ? finals : alt;
|
||||
if (cand.length > 0) {
|
||||
chosen = pick(cand).i;
|
||||
}
|
||||
} else {
|
||||
const weights = scale.map((p, i) => {
|
||||
if (i === idx) return motionWeight(0, false, repeats);
|
||||
const delta = p.midi - cur.midi;
|
||||
let w = motionWeight(delta, lastWasLeap, repeats);
|
||||
// A leap (≥5) prefers a subsequent step back toward its origin.
|
||||
if (lastWasLeap && Math.abs(delta) <= 2) {
|
||||
const stepBackToward = Math.sign(delta) === Math.sign(leapFromMidi - cur.midi) || leapFromMidi === cur.midi;
|
||||
if (stepBackToward) w = Math.max(w, 240);
|
||||
}
|
||||
return w;
|
||||
});
|
||||
const total = weights.reduce((a, b) => a + b, 0);
|
||||
if (total > 0) {
|
||||
let r = rng() * total;
|
||||
for (let i = 0; i < weights.length; i++) {
|
||||
r -= weights[i];
|
||||
if (r <= 0) { chosen = i; break; }
|
||||
}
|
||||
} else {
|
||||
chosen = Math.floor(rng() * scale.length);
|
||||
}
|
||||
}
|
||||
|
||||
const note = scale[chosen];
|
||||
const moved = Math.abs(note.midi - cur.midi);
|
||||
if (moved >= 5) { lastWasLeap = true; leapFromMidi = cur.midi; }
|
||||
else { lastWasLeap = false; leapFromMidi = 0; }
|
||||
if (note.midi === cur.midi) {
|
||||
if (!isFinal) repeats++;
|
||||
} else {
|
||||
repeats = 0;
|
||||
}
|
||||
|
||||
events.push({
|
||||
id: `n${slot}`,
|
||||
startTick: cursor,
|
||||
durationTicks: dur,
|
||||
pitch: { ...note.pitch },
|
||||
});
|
||||
cursor += dur;
|
||||
idx = chosen;
|
||||
}
|
||||
|
||||
const score: Score = {
|
||||
version: 1,
|
||||
id: `melody-${config.keyTonic}-${config.keyMode}-${meter.numerator}4-${bars}b-${rhythmLevel}-s${seed}`,
|
||||
title: `${key.tonic} ${key.degrees.length === 5 ? (config.keyMode === 'major-pentatonic' ? 'Major Pentatonic' : 'Minor Pentatonic') : 'Melody'}`,
|
||||
meter,
|
||||
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
|
||||
measures: Array.from({ length: bars }, (_, i) => ({
|
||||
number: i + 1,
|
||||
startTick: i * meter.numerator * PPQ,
|
||||
durationTicks: meter.numerator * PPQ,
|
||||
})),
|
||||
voices: [{ id: 'melody', events }],
|
||||
};
|
||||
const problems = validateScore(score);
|
||||
if (problems.length > 0) return { ok: false, error: `Internal generator error: ${problems[0]}` };
|
||||
return { ok: true, value: { score, seed } };
|
||||
}
|
||||
96
src/music/playableRange.ts
Normal file
96
src/music/playableRange.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Playable-note resolution, extracted from useGameLogic so both the
|
||||
* single-note modes and Phrase Mode share one tested implementation
|
||||
* (single-note behavior is unchanged).
|
||||
*/
|
||||
import { TUNINGS, getOpenStringNotes, getFirstPositionNotes, type Tuning, type FretPosition } from './Tunings';
|
||||
import { INSTRUMENT_DEFINITIONS } from './InstrumentConfigs';
|
||||
|
||||
export interface PlayableRangeInput {
|
||||
readonly instrumentId: string;
|
||||
readonly difficulty: string;
|
||||
readonly tuningId: string;
|
||||
readonly customMinFret?: number;
|
||||
readonly customMaxFret?: number;
|
||||
}
|
||||
|
||||
const sanitizeFret = (v: number | undefined, fallback: number): number => {
|
||||
if (v === undefined || v === null || Number.isNaN(v)) return fallback;
|
||||
return Math.max(0, Math.min(24, Math.round(v)));
|
||||
};
|
||||
|
||||
/** Sorted list of playable MIDI notes for the current instrument/range setup. */
|
||||
export function computePlayableNotes(input: PlayableRangeInput): number[] {
|
||||
const def = INSTRUMENT_DEFINITIONS[input.instrumentId];
|
||||
if (!def) return [];
|
||||
const tuning = TUNINGS[input.tuningId];
|
||||
const config = def.ranges.find(r => r.id === input.difficulty) ?? def.ranges[0];
|
||||
if (!config) return [];
|
||||
|
||||
if (config.type === 'open_strings') {
|
||||
if (tuning) return getOpenStringNotes(tuning);
|
||||
return config.notes ?? [];
|
||||
}
|
||||
if (config.type === 'first_position') {
|
||||
if (tuning) return getFirstPositionNotes(tuning);
|
||||
return config.notes ?? [];
|
||||
}
|
||||
if (config.type === 'custom_fret') {
|
||||
if (!tuning) return [];
|
||||
const minFret = sanitizeFret(input.customMinFret ?? config.defaultMinFret, 0);
|
||||
const maxFret = sanitizeFret(input.customMaxFret ?? config.defaultMaxFret, 12);
|
||||
return fretWindowNotes(tuning, minFret, maxFret);
|
||||
}
|
||||
if (config.type === 'specific_string') {
|
||||
if (!tuning || config.stringIndex === undefined) return [];
|
||||
const openNote = tuning.strings[config.stringIndex];
|
||||
if (openNote === undefined) return [];
|
||||
const notes: number[] = [];
|
||||
for (let i = 0; i <= 12; i++) notes.push(openNote + i);
|
||||
return notes;
|
||||
}
|
||||
if (config.notes) return config.notes;
|
||||
if (config.min !== undefined && config.max !== undefined) {
|
||||
const min = Math.max(0, Math.min(127, Math.round(config.min)));
|
||||
const max = Math.max(0, Math.min(127, Math.round(config.max)));
|
||||
return Array.from({ length: max - min + 1 }, (_, i) => min + i);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** All notes reachable within a fret window on the given tuning. */
|
||||
export function fretWindowNotes(tuning: Tuning, minFret: number, maxFret: number): number[] {
|
||||
const lo = sanitizeFret(minFret, 0);
|
||||
const hi = Math.max(lo, sanitizeFret(maxFret, 12));
|
||||
const notes = new Set<number>();
|
||||
for (const open of tuning.strings) {
|
||||
for (let fret = lo; fret <= hi; fret++) notes.add(open + fret);
|
||||
}
|
||||
return Array.from(notes).sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fretboard positions for a note, restricted to a fret window
|
||||
* (Phrase Mode hints must respect the selected position).
|
||||
*/
|
||||
export function positionsWithinWindow(
|
||||
midiNote: number,
|
||||
tuning: Tuning,
|
||||
minFret: number,
|
||||
maxFret: number,
|
||||
): FretPosition[] {
|
||||
const lo = sanitizeFret(minFret, 0);
|
||||
const hi = Math.max(lo, sanitizeFret(maxFret, 12));
|
||||
const positions: FretPosition[] = [];
|
||||
tuning.strings.forEach((open, stringIndex) => {
|
||||
const fret = midiNote - open;
|
||||
if (fret >= lo && fret <= hi) positions.push({ stringIndex, fret });
|
||||
});
|
||||
return positions;
|
||||
}
|
||||
|
||||
/** True when the instrument has a fretted tuning (guitar or bass). */
|
||||
export function isFrettedInstrument(instrumentId: string): boolean {
|
||||
const def = INSTRUMENT_DEFINITIONS[instrumentId];
|
||||
return !!def && (def.id === 'guitar' || def.id === 'bass');
|
||||
}
|
||||
130
src/music/scaleDrills.ts
Normal file
130
src/music/scaleDrills.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Scale drill generator (Phrase Mode) — required material, contract C4.
|
||||
*
|
||||
* Produces scale runs (up / down / up-and-down) as quarter notes with the
|
||||
* final note extended (with ties if needed) to complete its bar. One- and
|
||||
* two-octave drills require the COMPLETE scale path to fit the playable
|
||||
* pool — an incomplete "one-octave scale" is never emitted; a clear,
|
||||
* actionable error is returned instead.
|
||||
*/
|
||||
import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model';
|
||||
import { keyFor, scalePitches, type KeyContext, type ModeId } from './scales';
|
||||
|
||||
export type DrillDirection = 'up' | 'down' | 'updown';
|
||||
export type DrillCoverage = 'one-octave' | 'two-octave' | 'position';
|
||||
|
||||
export interface DrillConfig {
|
||||
readonly keyTonic: string;
|
||||
readonly keyMode: ModeId;
|
||||
readonly direction: DrillDirection;
|
||||
readonly coverage: DrillCoverage;
|
||||
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
|
||||
}
|
||||
|
||||
/** Complete ascending scale path from a starting tonic, if it fits the pool. */
|
||||
function octavePath(key: KeyContext, pool: number[], octaves: number, startMidi: number): number[] | null {
|
||||
const set = new Set(pool);
|
||||
const path: number[] = [startMidi];
|
||||
let cur = startMidi;
|
||||
for (let o = 0; o < octaves; o++) {
|
||||
// Skip the tonic at the start of each octave (it is already in `path`).
|
||||
for (let i = (o === 0 ? 1 : 0); i < key.pitchClasses.length; i++) {
|
||||
const pc = key.pitchClasses[i];
|
||||
const offset = ((pc - (cur % 12)) + 12) % 12;
|
||||
const candidate = cur + (offset === 0 ? 12 : offset);
|
||||
if (!set.has(candidate)) return null;
|
||||
path.push(candidate);
|
||||
cur = candidate;
|
||||
}
|
||||
}
|
||||
// Top tonic completes the requested octave span.
|
||||
if (!set.has(startMidi + 12 * octaves)) return null;
|
||||
path.push(startMidi + 12 * octaves);
|
||||
return path;
|
||||
}
|
||||
|
||||
export function generateScaleDrill(
|
||||
config: DrillConfig,
|
||||
eligiblePitches: number[],
|
||||
): Result<Score> {
|
||||
const key = keyFor(config.keyTonic, config.keyMode);
|
||||
const pool = Array.from(new Set(eligiblePitches.filter(m => Number.isInteger(m)))).sort((a, b) => a - b);
|
||||
if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' };
|
||||
|
||||
const scaleInPool = scalePitches(key, pool[0], pool[pool.length - 1]).filter(p => pool.includes(p.midi));
|
||||
if (scaleInPool.length === 0) return { ok: false, error: 'The selected key has no notes in the playable range — widen the range or change the key.' };
|
||||
|
||||
let path: number[];
|
||||
|
||||
if (config.coverage === 'position') {
|
||||
// Traverse the eligible scale notes in order (may start/end away from
|
||||
// the tonic — labeled as such in the UI, not a conventional fingering).
|
||||
const asc = scaleInPool.map(p => p.midi);
|
||||
if (asc.length < 2) return { ok: false, error: 'The selected position contains fewer than two scale notes — widen the fret window or change the key.' };
|
||||
if (config.direction === 'up') path = asc;
|
||||
else if (config.direction === 'down') path = [...asc].reverse();
|
||||
else path = [...asc, ...[...asc].reverse().slice(1)]; // up-down, no duplicate top
|
||||
} else {
|
||||
const octaves = config.coverage === 'one-octave' ? 1 : 2;
|
||||
// Find the lowest tonic start whose complete path fits the pool.
|
||||
const tonics = scaleInPool.filter(p => p.pitch.midi % 12 === key.pitchClasses[0]).map(p => p.midi);
|
||||
let found: number[] | null = null;
|
||||
for (const t of tonics) {
|
||||
found = octavePath(key, pool, octaves, t);
|
||||
if (found) break;
|
||||
}
|
||||
if (!found) {
|
||||
return {
|
||||
ok: false,
|
||||
error: octaves === 1
|
||||
? 'The complete one-octave scale does not fit the playable range — widen the range, or use the "within position" drill.'
|
||||
: 'The complete two-octave scale does not fit the playable range — use one octave or widen the range.',
|
||||
};
|
||||
}
|
||||
const up = found;
|
||||
if (config.direction === 'up') path = up;
|
||||
else if (config.direction === 'down') path = [...up].reverse();
|
||||
else path = [...up, ...[...up].reverse().slice(1)]; // no duplicate turning tonic
|
||||
}
|
||||
|
||||
// ---- Quarter-note rhythm, final note extended to fill its bar ---------
|
||||
const full = config.meter.numerator * PPQ;
|
||||
if (path.length * PPQ > 8 * full) {
|
||||
return { ok: false, error: 'This drill is longer than 8 bars — reduce the coverage or direction.' };
|
||||
}
|
||||
const barCount = Math.max(1, Math.ceil((path.length * PPQ) / full));
|
||||
const totalTicks = barCount * full;
|
||||
const lastDuration = PPQ + (totalTicks - path.length * PPQ);
|
||||
|
||||
const events: ScoreEvent[] = [];
|
||||
let cursor = 0;
|
||||
const spellFor = (midi: number) => {
|
||||
const sp = scalePitches(key, midi, midi);
|
||||
const found = sp.find(p => p.midi === midi && pool.includes(p.midi));
|
||||
return found ? found.pitch : sp[0]?.pitch ?? null;
|
||||
};
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const dur = i === path.length - 1 ? lastDuration : PPQ;
|
||||
const pitch = spellFor(path[i]);
|
||||
if (!pitch) return { ok: false, error: 'Internal error spelling drill pitch.' };
|
||||
events.push({ id: `s${i}`, startTick: cursor, durationTicks: dur, pitch });
|
||||
cursor += dur;
|
||||
}
|
||||
|
||||
const score: Score = {
|
||||
version: 1,
|
||||
id: `scale-${config.keyTonic}-${config.keyMode}-${config.coverage}-${config.direction}-${config.meter.numerator}4`,
|
||||
title: `${key.tonic} scale — ${config.coverage === 'position' ? 'within position' : config.coverage} ${config.direction}`,
|
||||
meter: config.meter,
|
||||
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
|
||||
measures: Array.from({ length: barCount }, (_, i) => ({
|
||||
number: i + 1,
|
||||
startTick: i * full,
|
||||
durationTicks: full,
|
||||
})),
|
||||
voices: [{ id: 'melody', events }],
|
||||
};
|
||||
const problems = validateScore(score);
|
||||
if (problems.length > 0) return { ok: false, error: `Internal drill error: ${problems[0]}` };
|
||||
return { ok: true, value: score };
|
||||
}
|
||||
113
src/music/scales.test.ts
Normal file
113
src/music/scales.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { keyFor, scalePitches, spelledPitch, spellPcInKey, MODE_LABELS, isMode } from './scales';
|
||||
import { validateScore } from '../score/model';
|
||||
|
||||
describe('keyFor — signatures and spelling', () => {
|
||||
it('derives parent signatures for all 12 tonics (major)', () => {
|
||||
const expected: Record<string, string> = {
|
||||
C: 'C', G: 'G', D: 'D', A: 'A', E: 'E', B: 'B',
|
||||
'F#': 'F#', Db: 'Db', Ab: 'Ab', Eb: 'Eb', Bb: 'Bb', F: 'F',
|
||||
};
|
||||
for (const [tonic, sig] of Object.entries(expected)) {
|
||||
expect(keyFor(tonic, 'major').signature).toBe(sig);
|
||||
}
|
||||
});
|
||||
|
||||
it('derives correct parent signatures for minor and modes', () => {
|
||||
expect(keyFor('A', 'minor').signature).toBe('C'); // A minor ↔ C major
|
||||
expect(keyFor('E', 'minor').signature).toBe('G');
|
||||
expect(keyFor('B', 'minor').signature).toBe('D');
|
||||
expect(keyFor('D', 'dorian').signature).toBe('C'); // D dorian ↔ C major
|
||||
expect(keyFor('G', 'mixolydian').signature).toBe('C');
|
||||
expect(keyFor('A', 'minor-pentatonic').signature).toBe('C');
|
||||
expect(keyFor('C', 'major-pentatonic').signature).toBe('C');
|
||||
});
|
||||
|
||||
it('spells E# in F# major and B# in C# major (contract I)', () => {
|
||||
const fsharp = keyFor('F#', 'major');
|
||||
expect(fsharp.signatureAlter.F).toBe(1);
|
||||
const eSharp = fsharp.degrees.find(d => d.semitones === 11);
|
||||
expect(eSharp).toEqual({ step: 'E', alter: 1, semitones: 11 });
|
||||
|
||||
const csharp = keyFor('C#', 'minor'); // parent E major: D# in signature
|
||||
expect(csharp.signature).toBe('E');
|
||||
// C# natural minor 7th = B → spelled B (signature has no B accidental)
|
||||
const seventh = csharp.degrees.find(d => d.semitones === 10);
|
||||
expect(seventh).toEqual({ step: 'B', alter: 0, semitones: 10 });
|
||||
});
|
||||
|
||||
it('produces correct pitch classes for representative scales', () => {
|
||||
expect(keyFor('C', 'major').pitchClasses).toEqual([0, 2, 4, 5, 7, 9, 11]);
|
||||
expect(keyFor('A', 'minor').pitchClasses).toEqual([9, 11, 0, 2, 4, 5, 7]);
|
||||
expect(keyFor('A', 'minor-pentatonic').pitchClasses).toEqual([9, 0, 2, 4, 7]);
|
||||
expect(keyFor('C', 'major-pentatonic').pitchClasses).toEqual([0, 2, 4, 7, 9]);
|
||||
expect(keyFor('D', 'lydian').pitchClasses).toEqual([2, 4, 6, 8, 9, 11, 1]);
|
||||
expect(keyFor('E', 'phrygian').pitchClasses).toEqual([4, 5, 7, 9, 11, 0, 2]);
|
||||
});
|
||||
|
||||
it('spells all degrees consistently with pitch classes', () => {
|
||||
for (const mode of Object.keys(MODE_LABELS)) {
|
||||
if (!isMode(mode)) continue;
|
||||
for (const tonic of ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'Db', 'Ab', 'Eb', 'Bb', 'F']) {
|
||||
const key = keyFor(tonic, mode);
|
||||
for (const d of key.degrees) {
|
||||
expect(Math.abs(d.alter)).toBeLessThanOrEqual(2);
|
||||
expect(d.step).toBeTruthy();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('spellPcInKey returns degree spelling or null', () => {
|
||||
const g = keyFor('G', 'major');
|
||||
const fSharp = spellPcInKey(6, g);
|
||||
expect(fSharp).toEqual({ step: 'F', alter: 1 });
|
||||
expect(spellPcInKey(5, g)).toBeNull(); // F natural not in G major
|
||||
});
|
||||
});
|
||||
|
||||
describe('scalePitches', () => {
|
||||
it('enumerates C major across a range with correct spelling and octaves', () => {
|
||||
const key = keyFor('C', 'major');
|
||||
const pitches = scalePitches(key, 60, 72);
|
||||
expect(pitches.map(p => p.midi)).toEqual([60, 62, 64, 65, 67, 69, 71, 72]);
|
||||
expect(pitches[7].pitch.step).toBe('C');
|
||||
expect(pitches[7].pitch.octave).toBe(5);
|
||||
expect(pitches[0].degreeIndex).toBe(0);
|
||||
expect(pitches[7].degreeIndex).toBe(0); // octave-up tonic maps to degree 0
|
||||
});
|
||||
|
||||
it('spells octave correctly at boundaries (B3/C4)', () => {
|
||||
const key = keyFor('C', 'major');
|
||||
const b = scalePitches(key, 59, 60);
|
||||
expect(b[0].pitch.step).toBe('B');
|
||||
expect(b[0].pitch.octave).toBe(3);
|
||||
expect(b[1].pitch.step).toBe('C');
|
||||
expect(b[1].pitch.octave).toBe(4);
|
||||
});
|
||||
|
||||
it('spelledPitch rejects mismatches', () => {
|
||||
expect(() => spelledPitch(61, 'C', 0)).toThrow();
|
||||
expect(spelledPitch(61, 'C', 1)).toMatchObject({ midi: 61, step: 'C', alter: 1, octave: 4 });
|
||||
});
|
||||
|
||||
it('scale pitches can build a valid score (integration sanity)', () => {
|
||||
const key = keyFor('Eb', 'major');
|
||||
const pitches = scalePitches(key, 60, 71).slice(0, 7); // C D E F G A B
|
||||
const events = pitches.map((p, i) => ({
|
||||
id: `e${i}`,
|
||||
startTick: i * 480,
|
||||
durationTicks: 480,
|
||||
pitch: p.pitch,
|
||||
}));
|
||||
const score = {
|
||||
version: 1 as const,
|
||||
id: 'x', title: 'x',
|
||||
meter: { numerator: 4 as const, denominator: 4 as const },
|
||||
key: { tonic: 'Eb', mode: 'major', signature: key.signature },
|
||||
measures: [{ number: 1, startTick: 0, durationTicks: 1920 }, { number: 2, startTick: 1920, durationTicks: 1920 }],
|
||||
voices: [{ id: 'melody' as const, events: [...events, { id: 'pad', startTick: 7 * 480, durationTicks: 8 * 480 - 7 * 480, pitch: null }] }],
|
||||
};
|
||||
expect(validateScore(score)).toEqual([]);
|
||||
});
|
||||
});
|
||||
183
src/music/scales.ts
Normal file
183
src/music/scales.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Scales, modes and key spelling for Phrase Mode.
|
||||
*
|
||||
* All 12 tonic pitch classes are supported. Modes are defined by
|
||||
* {letterOffset, semitones} degree pairs so enharmonic spelling is derived
|
||||
* (E# in F# major, B# in C# major, Cb in Ab major, ...) instead of guessed
|
||||
* from a chromatic name array — the existing `getNoteInKey` in NoteUtils.ts
|
||||
* is limited to 8 major + 3 minor signatures and stays untouched for the
|
||||
* single-note modes.
|
||||
*/
|
||||
import { STEP_LETTERS, LETTER_PC, type StepLetter, type SpelledPitch } from '../score/model';
|
||||
|
||||
export type ModeId =
|
||||
| 'major' | 'minor'
|
||||
| 'dorian' | 'phrygian' | 'lydian' | 'mixolydian' | 'locrian'
|
||||
| 'major-pentatonic' | 'minor-pentatonic';
|
||||
|
||||
export interface Degree {
|
||||
/** Letter distance from the tonic letter (0 = tonic). */
|
||||
readonly letterOffset: number;
|
||||
/** Semitone distance from the (spelled) tonic pitch class. */
|
||||
readonly semitones: number;
|
||||
}
|
||||
|
||||
export const SCALE_MODES: Record<ModeId, Degree[]> = {
|
||||
major: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 4 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }, { letterOffset: 6, semitones: 11 }],
|
||||
minor: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 8 }, { letterOffset: 6, semitones: 10 }],
|
||||
dorian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }, { letterOffset: 6, semitones: 10 }],
|
||||
phrygian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 1 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 8 }, { letterOffset: 6, semitones: 10 }],
|
||||
lydian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 4 }, { letterOffset: 3, semitones: 6 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }, { letterOffset: 6, semitones: 11 }],
|
||||
mixolydian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 4 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }, { letterOffset: 6, semitones: 10 }],
|
||||
locrian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 1 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 6 }, { letterOffset: 5, semitones: 8 }, { letterOffset: 6, semitones: 10 }],
|
||||
'major-pentatonic': [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 4 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }],
|
||||
'minor-pentatonic': [{ letterOffset: 0, semitones: 0 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 6, semitones: 10 }],
|
||||
};
|
||||
|
||||
export const MODE_LABELS: Record<ModeId, string> = {
|
||||
major: 'Major',
|
||||
minor: 'Minor',
|
||||
dorian: 'Dorian',
|
||||
phrygian: 'Phrygian',
|
||||
lydian: 'Lydian',
|
||||
mixolydian: 'Mixolydian',
|
||||
locrian: 'Locrian',
|
||||
'major-pentatonic': 'Major Pentatonic',
|
||||
'minor-pentatonic': 'Minor Pentatonic',
|
||||
};
|
||||
|
||||
/** Parent-major offset per mode: parentPc = (tonicPc - offset + 12) % 12. */
|
||||
const MODE_PARENT_OFFSET: Record<ModeId, number> = {
|
||||
major: 0, minor: 9, dorian: 2, phrygian: 4, lydian: 5, mixolydian: 7,
|
||||
locrian: 11, 'major-pentatonic': 0, 'minor-pentatonic': 9,
|
||||
};
|
||||
|
||||
/** Conventional tonic names covering all 12 pitch classes. */
|
||||
export const TONICS: Record<string, { pc: number; step: StepLetter; alter: number }> = {
|
||||
'C': { pc: 0, step: 'C', alter: 0 },
|
||||
'G': { pc: 7, step: 'G', alter: 0 },
|
||||
'D': { pc: 2, step: 'D', alter: 0 },
|
||||
'A': { pc: 9, step: 'A', alter: 0 },
|
||||
'E': { pc: 4, step: 'E', alter: 0 },
|
||||
'B': { pc: 11, step: 'B', alter: 0 },
|
||||
'F#': { pc: 6, step: 'F', alter: 1 },
|
||||
'Db': { pc: 1, step: 'D', alter: -1 },
|
||||
'Ab': { pc: 8, step: 'A', alter: -1 },
|
||||
'Eb': { pc: 3, step: 'E', alter: -1 },
|
||||
'Bb': { pc: 10, step: 'B', alter: -1 },
|
||||
'F': { pc: 5, step: 'F', alter: 0 },
|
||||
// Conventional aliases accepted in settings/imports.
|
||||
'C#': { pc: 1, step: 'C', alter: 1 },
|
||||
'Gb': { pc: 6, step: 'G', alter: -1 },
|
||||
'D#': { pc: 3, step: 'D', alter: 1 },
|
||||
'G#': { pc: 8, step: 'G', alter: 1 },
|
||||
'A#': { pc: 10, step: 'A', alter: 1 },
|
||||
};
|
||||
|
||||
/** Major key signatures by pitch class of the tonic (max 7 accidentals). */
|
||||
const MAJOR_SIGNATURES: Record<number, { name: string; sharps: string[]; flats: string[] }> = {
|
||||
0: { name: 'C', sharps: [], flats: [] },
|
||||
7: { name: 'G', sharps: ['F#'], flats: [] },
|
||||
2: { name: 'D', sharps: ['F#', 'C#'], flats: [] },
|
||||
9: { name: 'A', sharps: ['F#', 'C#', 'G#'], flats: [] },
|
||||
4: { name: 'E', sharps: ['F#', 'C#', 'G#', 'D#'], flats: [] },
|
||||
11: { name: 'B', sharps: ['F#', 'C#', 'G#', 'D#', 'A#'], flats: [] },
|
||||
6: { name: 'F#', sharps: ['F#', 'C#', 'G#', 'D#', 'A#', 'E#'], flats: [] },
|
||||
1: { name: 'Db', sharps: [], flats: ['Bb', 'Eb', 'Ab', 'Db', 'Gb'] },
|
||||
8: { name: 'Ab', sharps: [], flats: ['Bb', 'Eb', 'Ab', 'Db'] },
|
||||
3: { name: 'Eb', sharps: [], flats: ['Bb', 'Eb', 'Ab'] },
|
||||
10: { name: 'Bb', sharps: [], flats: ['Bb', 'Eb'] },
|
||||
5: { name: 'F', sharps: [], flats: ['Bb'] },
|
||||
};
|
||||
|
||||
export interface KeyContext {
|
||||
readonly tonic: string;
|
||||
readonly mode: ModeId;
|
||||
/** Parent major key name (also the VexFlow key-signature spec). */
|
||||
readonly signature: string;
|
||||
/** Alter expected by the key signature per step letter. */
|
||||
readonly signatureAlter: Record<StepLetter, number>;
|
||||
/** Spelled degrees of the scale (length 5 or 7). */
|
||||
readonly degrees: Array<{ step: StepLetter; alter: number; semitones: number }>;
|
||||
/** Pitch classes (sounding) of the scale. */
|
||||
readonly pitchClasses: number[];
|
||||
}
|
||||
|
||||
export function isTonic(name: string): boolean {
|
||||
return name in TONICS;
|
||||
}
|
||||
|
||||
export function isMode(mode: string): mode is ModeId {
|
||||
return mode in SCALE_MODES;
|
||||
}
|
||||
|
||||
export function keyFor(tonicName: string, modeId: ModeId): KeyContext {
|
||||
const tonic = TONICS[tonicName];
|
||||
if (!tonic) throw new Error(`Unknown tonic: ${tonicName}`);
|
||||
const parentPc = (tonic.pc - MODE_PARENT_OFFSET[modeId] + 12) % 12;
|
||||
const sig = MAJOR_SIGNATURES[parentPc];
|
||||
|
||||
const signatureAlter = {} as Record<StepLetter, number>;
|
||||
for (const s of STEP_LETTERS) signatureAlter[s] = 0;
|
||||
for (const acc of sig.sharps) signatureAlter[acc[0] as StepLetter] = 1;
|
||||
for (const acc of sig.flats) signatureAlter[acc[0] as StepLetter] = -1;
|
||||
|
||||
const tonicLetterIdx = STEP_LETTERS.indexOf(tonic.step);
|
||||
const tonicPc = (LETTER_PC[STEP_LETTERS.indexOf(tonic.step)] + tonic.alter + 12) % 12;
|
||||
const degrees = SCALE_MODES[modeId].map(d => {
|
||||
const step = STEP_LETTERS[(tonicLetterIdx + d.letterOffset) % 7];
|
||||
const targetPc = (tonicPc + d.semitones) % 12;
|
||||
const stepPc = LETTER_PC[STEP_LETTERS.indexOf(step)];
|
||||
let alter = ((targetPc - stepPc) % 12 + 12) % 12;
|
||||
if (alter > 6) alter -= 12;
|
||||
return { step, alter, semitones: d.semitones };
|
||||
});
|
||||
|
||||
const pitchClasses = degrees.map(d => (LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 12) % 12);
|
||||
return { tonic: tonicName, mode: modeId, signature: sig.name, signatureAlter, degrees, pitchClasses };
|
||||
}
|
||||
|
||||
/** VexFlow key-signature spec for the parent major. */
|
||||
export function vexflowKeySpec(key: KeyContext): string {
|
||||
return key.signature;
|
||||
}
|
||||
|
||||
/** Spell a sounding midi note as the given (step, alter), computing octave. */
|
||||
export function spelledPitch(midi: number, step: StepLetter, alter: number): SpelledPitch {
|
||||
const pc = ((LETTER_PC[STEP_LETTERS.indexOf(step)] + alter) % 12 + 12) % 12;
|
||||
const octave = (midi - pc) / 12 - 1;
|
||||
if (!Number.isInteger(octave)) throw new Error(`Spelling ${step}${alter} does not match midi ${midi}`);
|
||||
return { midi, step, alter, octave };
|
||||
}
|
||||
|
||||
/** Spelling of a scale pitch class in the key (degree spelling). */
|
||||
export function spellPcInKey(pc: number, key: KeyContext): { step: StepLetter; alter: number } | null {
|
||||
const normalized = ((pc % 12) + 12) % 12;
|
||||
const degree = key.degrees.find(d => (LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 12) % 12 === normalized);
|
||||
return degree ? { step: degree.step, alter: degree.alter } : null;
|
||||
}
|
||||
|
||||
export interface ScalePitch {
|
||||
readonly midi: number;
|
||||
readonly pitch: SpelledPitch;
|
||||
/** Index into key.degrees (used for step-motion preference). */
|
||||
readonly degreeIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* All scale notes in [minMidi, maxMidi], spelled in the key.
|
||||
* Returned ascending. Each entry knows its scale-degree index so the melody
|
||||
* generator can prefer adjacent-degree motion.
|
||||
*/
|
||||
export function scalePitches(key: KeyContext, minMidi: number, maxMidi: number): ScalePitch[] {
|
||||
const out: ScalePitch[] = [];
|
||||
if (maxMidi < minMidi) return out;
|
||||
for (let midi = Math.max(0, Math.floor(minMidi)); midi <= Math.min(127, Math.ceil(maxMidi)); midi++) {
|
||||
const pc = ((midi % 12) + 12) % 12;
|
||||
const degreeIndex = key.pitchClasses.indexOf(pc);
|
||||
if (degreeIndex === -1) continue;
|
||||
const d = key.degrees[degreeIndex];
|
||||
out.push({ midi, pitch: spelledPitch(midi, d.step, d.alter), degreeIndex });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
127
src/score/model.test.ts
Normal file
127
src/score/model.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { PPQ, validateScore, sliceScore, scoreFitsPool, type Score, type ScoreMeasure, type ScoreEvent } from './model';
|
||||
import { spelledPitch } from '../music/scales';
|
||||
|
||||
const C = (octave: number, alter = 0) => spelledPitch(12 * (octave + 1) + alter, 'C', alter);
|
||||
|
||||
function simpleScore(): Score {
|
||||
// 4/4, two full bars of quarters: C4 D4 E4 F4 | G4 (half) A4 (half)
|
||||
return {
|
||||
version: 1,
|
||||
id: 'test',
|
||||
title: 'Test',
|
||||
meter: { numerator: 4, denominator: 4 },
|
||||
key: { tonic: 'C', mode: 'major', signature: 'C' },
|
||||
measures: [
|
||||
{ number: 1, startTick: 0, durationTicks: 4 * PPQ },
|
||||
{ number: 2, startTick: 4 * PPQ, durationTicks: 4 * PPQ },
|
||||
],
|
||||
voices: [{
|
||||
id: 'melody',
|
||||
events: [
|
||||
{ id: 'a', startTick: 0, durationTicks: PPQ, pitch: C(4) },
|
||||
{ id: 'b', startTick: PPQ, durationTicks: PPQ, pitch: C(4) },
|
||||
{ id: 'c', startTick: 2 * PPQ, durationTicks: PPQ, pitch: C(4) },
|
||||
{ id: 'd', startTick: 3 * PPQ, durationTicks: PPQ, pitch: C(4) },
|
||||
{ id: 'e', startTick: 4 * PPQ, durationTicks: 2 * PPQ, pitch: C(5) },
|
||||
{ id: 'f', startTick: 6 * PPQ, durationTicks: 2 * PPQ, pitch: C(5) },
|
||||
],
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe('validateScore', () => {
|
||||
it('accepts a well-formed score', () => {
|
||||
expect(validateScore(simpleScore())).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects non-contiguous events', () => {
|
||||
const s = simpleScore();
|
||||
s.voices[0].events[1] = { ...s.voices[0].events[1], startTick: 3 * PPQ };
|
||||
expect(validateScore(s).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects empty and all-rest scores', () => {
|
||||
const s = simpleScore();
|
||||
const rests = s.voices[0].events.map((e, i) => ({ ...e, id: `r${i}`, pitch: null }));
|
||||
const allRests: Score = { ...s, voices: [{ id: 'melody', events: rests }] };
|
||||
expect(validateScore(allRests).length).toBeGreaterThan(0);
|
||||
expect(validateScore({ ...s, voices: [{ id: 'melody', events: [] }] }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('rejects short middle measures', () => {
|
||||
const s: Score = { ...simpleScore() };
|
||||
(s as { measures: ScoreMeasure[] }).measures = [
|
||||
{ number: 1, startTick: 0, durationTicks: 4 * PPQ },
|
||||
{ number: 2, startTick: 4 * PPQ, durationTicks: 3 * PPQ },
|
||||
];
|
||||
expect(validateScore(s).some(p => p.includes('measure 2'))).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts pickup + complementary final measure', () => {
|
||||
const s: Score = { ...simpleScore() };
|
||||
// 1-beat pickup, final bar 3 beats. Measures: 1 + 4 + 3 = 8 beats.
|
||||
(s as { measures: ScoreMeasure[] }).measures = [
|
||||
{ number: 1, startTick: 0, durationTicks: PPQ },
|
||||
{ number: 2, startTick: PPQ, durationTicks: 4 * PPQ },
|
||||
{ number: 3, startTick: 5 * PPQ, durationTicks: 3 * PPQ },
|
||||
];
|
||||
expect(validateScore(s)).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a short final measure without pickup', () => {
|
||||
const s: Score = { ...simpleScore() };
|
||||
(s as { measures: ScoreMeasure[] }).measures = [
|
||||
{ number: 1, startTick: 0, durationTicks: 4 * PPQ },
|
||||
{ number: 2, startTick: 4 * PPQ, durationTicks: 3 * PPQ },
|
||||
];
|
||||
expect(validateScore(s).some(p => p.includes('final measure'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sliceScore', () => {
|
||||
it('keeps the whole score and rebases tick 0', () => {
|
||||
const sliced = sliceScore(simpleScore(), 1, 2);
|
||||
expect(sliced.measures).toHaveLength(2);
|
||||
expect(sliced.measures[0].startTick).toBe(0);
|
||||
expect(sliced.measures[1].startTick).toBe(4 * PPQ);
|
||||
expect(sliceScore(simpleScore(), 1, 2).voices[0].events).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('slices a single middle bar and clips overlapping events', () => {
|
||||
const sliced = sliceScore(simpleScore(), 2, 1);
|
||||
expect(sliced.measures).toHaveLength(1);
|
||||
expect(sliced.measures[0].number).toBe(2);
|
||||
const evts = sliced.voices[0].events;
|
||||
// Bar 2 originally: half G4 (clip to 2 beats? no: slice keeps whole bar) + half A4
|
||||
expect(evts).toHaveLength(2);
|
||||
expect(evts[0].startTick).toBe(0);
|
||||
expect(evts[0].durationTicks).toBe(2 * PPQ);
|
||||
});
|
||||
|
||||
it('turns a tie entering the slice into a fresh attack at tick 0', () => {
|
||||
// One event spanning both bars (a merged tie), slice bar 2.
|
||||
const s: Score = { ...simpleScore() };
|
||||
(s.voices[0] as { events: ScoreEvent[] }).events = [
|
||||
{ id: 'long', startTick: 0, durationTicks: 8 * PPQ, pitch: C(4) },
|
||||
];
|
||||
const sliced = sliceScore(s, 2, 1);
|
||||
expect(sliced.voices[0].events).toHaveLength(1);
|
||||
expect(sliced.voices[0].events[0].startTick).toBe(0);
|
||||
expect(sliced.voices[0].events[0].durationTicks).toBe(4 * PPQ);
|
||||
});
|
||||
|
||||
it('preserves bar numbers and id suffix', () => {
|
||||
const sliced = sliceScore(simpleScore(), 2, 1);
|
||||
expect(sliced.id).toContain(':2+1');
|
||||
expect(sliced.measures[0].number).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('scoreFitsPool', () => {
|
||||
it('detects out-of-pool pitches', () => {
|
||||
const s = simpleScore();
|
||||
expect(scoreFitsPool(s, [60, 62, 64, 65, 67, 69, 72])).toBe(true);
|
||||
expect(scoreFitsPool(s, [60])).toBe(false);
|
||||
});
|
||||
});
|
||||
247
src/score/model.ts
Normal file
247
src/score/model.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* Normalized score model (Phrase Mode).
|
||||
*
|
||||
* Versioned, interchange-independent representation of a single-voice musical
|
||||
* line. Both the generators (melody/scale drills) and the ABC import adapter
|
||||
* produce it; the VexFlow renderer and the phrase matcher consume it — neither
|
||||
* ever sees raw ABC. This keeps a future MIDI/MusicXML adapter possible
|
||||
* without touching the trainer or renderer.
|
||||
*
|
||||
* Key decisions (see docs/PHRASE_MODE_IMPLEMENTATION.md, section B):
|
||||
* - Integer ticks at 480 per quarter note (PPQ). No floating beat arithmetic.
|
||||
* - Ties are ALWAYS pre-merged: one ScoreEvent = one required attack ("logical
|
||||
* note"). The renderer re-splits such events into tied glyphs at barlines;
|
||||
* the matcher and the summary count them exactly once.
|
||||
* - Pitches are stored SOUNDING, with preserved enharmonic spelling. The
|
||||
* instrument display transpose is applied once, at render time only.
|
||||
* - Playback state (cursor, results, seed, ...) NEVER lives on the score.
|
||||
*/
|
||||
|
||||
/** Ticks per quarter note. */
|
||||
export const PPQ = 480;
|
||||
|
||||
/** Simple result wrapper used by generators and the import adapter. */
|
||||
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
export const STEP_LETTERS = ['C', 'D', 'E', 'F', 'G', 'A', 'B'] as const;
|
||||
/** Sounding pitch class of each natural letter (C=0 ... B=11). */
|
||||
export const LETTER_PC = [0, 2, 4, 5, 7, 9, 11] as const;
|
||||
export type StepLetter = (typeof STEP_LETTERS)[number];
|
||||
|
||||
/** Written (notated) pitch with explicit enharmonic spelling. */
|
||||
export interface SpelledPitch {
|
||||
/** Sounding MIDI note number (0..127). */
|
||||
readonly midi: number;
|
||||
readonly step: StepLetter;
|
||||
/** -2..2; 0 = natural, 1 = sharp, -1 = flat, ±2 = double. */
|
||||
readonly alter: number;
|
||||
/** Spelling octave: written midi = 12 * (octave + 1) + stepPc + alter. */
|
||||
readonly octave: number;
|
||||
}
|
||||
|
||||
/** One required attack (a "logical note"); null pitch = rest. */
|
||||
export interface ScoreEvent {
|
||||
/** Stable identity through rendering/retries (unique within a score). */
|
||||
readonly id: string;
|
||||
readonly startTick: number;
|
||||
readonly durationTicks: number;
|
||||
readonly pitch: SpelledPitch | null;
|
||||
}
|
||||
|
||||
export interface ScoreMeasure {
|
||||
/** 1-based original bar number (kept through slicing for orientation). */
|
||||
readonly number: number;
|
||||
readonly startTick: number;
|
||||
readonly durationTicks: number;
|
||||
}
|
||||
|
||||
export interface ScoreKey {
|
||||
/** Tonic spelling as displayed, e.g. "C", "F#", "Bb". */
|
||||
readonly tonic: string;
|
||||
/** Mode id, e.g. "major", "minor", "dorian", "major-pentatonic". */
|
||||
readonly mode: string;
|
||||
/** Parent major key signature name for notation (e.g. "Bb" for G minor). */
|
||||
readonly signature: string;
|
||||
}
|
||||
|
||||
export interface ScoreSource {
|
||||
readonly composer?: string;
|
||||
readonly work?: string;
|
||||
/** Identification of the exact part (exercise number, measures, ...). */
|
||||
readonly locator?: string;
|
||||
readonly url?: string;
|
||||
/** Rights of BOTH source work and this transcription. */
|
||||
readonly rights: string;
|
||||
/** What was changed relative to the source, if anything. */
|
||||
readonly adaptation?: string;
|
||||
/**
|
||||
* Semitones from WRITTEN source pitch to sounding pitch that the import
|
||||
* already applied (e.g. -12 for explicit guitar-convention input).
|
||||
* Renderer/matching still operate on the stored sounding `midi`.
|
||||
*/
|
||||
readonly writtenToSoundingSemitones?: number;
|
||||
}
|
||||
|
||||
export interface Score {
|
||||
readonly version: 1;
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
|
||||
readonly key: ScoreKey;
|
||||
readonly measures: ScoreMeasure[];
|
||||
/** Exactly one voice in v1. */
|
||||
readonly voices: ReadonlyArray<{ readonly id: 'melody'; readonly events: ScoreEvent[] }>;
|
||||
readonly source?: ScoreSource;
|
||||
}
|
||||
|
||||
export const FULL_MEASURE_TICKS = (meter: { numerator: number }): number =>
|
||||
meter.numerator * PPQ;
|
||||
|
||||
export function scoreEvents(score: Score): ScoreEvent[] {
|
||||
return score.voices[0]?.events ?? [];
|
||||
}
|
||||
|
||||
export function totalDurationTicks(score: Score): number {
|
||||
const events = scoreEvents(score);
|
||||
if (events.length === 0) return 0;
|
||||
const last = events[events.length - 1];
|
||||
return last.startTick + last.durationTicks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural validation. Returns a list of problems (empty = valid).
|
||||
* Imported and generated scores both pass through this before use.
|
||||
*/
|
||||
export function validateScore(score: Score): string[] {
|
||||
const problems: string[] = [];
|
||||
const events = scoreEvents(score);
|
||||
const full = FULL_MEASURE_TICKS(score.meter);
|
||||
|
||||
if (score.voices.length !== 1) problems.push('expected exactly one voice');
|
||||
if (score.measures.length === 0) problems.push('score has no measures');
|
||||
|
||||
let expectedStart = 0;
|
||||
for (const e of events) {
|
||||
if (!Number.isInteger(e.startTick) || e.startTick < 0) {
|
||||
problems.push(`event ${e.id}: invalid startTick ${e.startTick}`);
|
||||
}
|
||||
if (!Number.isInteger(e.durationTicks) || e.durationTicks <= 0) {
|
||||
problems.push(`event ${e.id}: invalid durationTicks ${e.durationTicks}`);
|
||||
}
|
||||
if (e.startTick !== expectedStart) {
|
||||
problems.push(`event ${e.id}: expected start ${expectedStart}, got ${e.startTick} (events must be contiguous)`);
|
||||
}
|
||||
expectedStart = e.startTick + e.durationTicks;
|
||||
if (e.pitch) {
|
||||
const { midi, step, alter, octave } = e.pitch;
|
||||
if (!Number.isInteger(midi) || midi < 0 || midi > 127) {
|
||||
problems.push(`event ${e.id}: midi ${midi} out of range`);
|
||||
}
|
||||
if (alter < -2 || alter > 2) {
|
||||
problems.push(`event ${e.id}: alter ${alter} out of range`);
|
||||
}
|
||||
const written = 12 * (octave + 1) + (LETTER_PC[STEP_LETTERS.indexOf(step)] + alter);
|
||||
if (written % 12 !== ((midi % 12) + 12) % 12) {
|
||||
problems.push(`event ${e.id}: spelling does not match midi (${step}${alter || ''} octave ${octave} vs midi ${midi})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of score.measures) {
|
||||
if (!Number.isInteger(m.startTick) || m.startTick < 0) {
|
||||
problems.push(`measure ${m.number}: invalid startTick`);
|
||||
}
|
||||
if (m.startTick % full !== 0 && m.durationTicks >= full) {
|
||||
// Only pickups/closers may be misaligned; flagged via duration below.
|
||||
}
|
||||
}
|
||||
|
||||
// Measure durations: all full, or first+last complement each other.
|
||||
if (score.measures.length > 0) {
|
||||
const first = score.measures[0];
|
||||
const last = score.measures[score.measures.length - 1];
|
||||
const others = score.measures.slice(1, -1);
|
||||
for (const m of others) {
|
||||
if (m.durationTicks !== full) {
|
||||
problems.push(`measure ${m.number}: ${m.durationTicks} ticks (expected ${full} for ${score.meter.numerator}/4)`);
|
||||
}
|
||||
}
|
||||
if (score.measures.length === 1 && first.durationTicks !== full) {
|
||||
problems.push(`measure ${first.number}: single measure must be full`);
|
||||
}
|
||||
if (score.measures.length > 1) {
|
||||
if (first.durationTicks !== full && last.durationTicks !== full && first.durationTicks + last.durationTicks !== full) {
|
||||
problems.push(
|
||||
`pickup/final measures must be full or complement (${first.durationTicks} + ${last.durationTicks} != ${full})`
|
||||
);
|
||||
}
|
||||
if (first.durationTicks === full && last.durationTicks !== full) {
|
||||
// A short final bar without a pickup is only valid if it complements... reject.
|
||||
problems.push(`final measure ${last.number} is short but there is no pickup measure`);
|
||||
}
|
||||
}
|
||||
// Contiguity of measures.
|
||||
let mStart = 0;
|
||||
for (const m of score.measures) {
|
||||
if (m.startTick !== mStart) {
|
||||
problems.push(`measure ${m.number}: expected start ${mStart}, got ${m.startTick}`);
|
||||
}
|
||||
mStart += m.durationTicks;
|
||||
}
|
||||
// The last measure must end exactly at the last event's end.
|
||||
const endTick = mStart;
|
||||
if (events.length > 0 && endTick !== expectedStart) {
|
||||
problems.push(`events end at ${expectedStart} but measures end at ${endTick}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Monophonic by construction (contiguity above covers overlap). Rests-only:
|
||||
if (events.length === 0 || events.every(e => e.pitch === null)) {
|
||||
problems.push('score contains no playable notes');
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a practice range: measures [startBar, startBar+barCount).
|
||||
* Events overlapping the range are clipped and rebased to tick 0; a tie
|
||||
* entering the range becomes a fresh attack (its clipped fragment), exactly
|
||||
* as specified in the contract. Original bar numbers are preserved.
|
||||
*/
|
||||
export function sliceScore(score: Score, startBar: number, barCount: number): Score {
|
||||
const total = score.measures.length;
|
||||
const from = Math.max(1, Math.min(startBar, total));
|
||||
const count = Math.max(1, Math.min(barCount, total - from + 1));
|
||||
const measures = score.measures.slice(from - 1, from - 1 + count);
|
||||
if (measures.length === 0) return { ...score, measures: [], voices: [{ id: 'melody', events: [] }] };
|
||||
|
||||
const startTick = measures[0].startTick;
|
||||
const endTick = measures[measures.length - 1].startTick + measures[measures.length - 1].durationTicks;
|
||||
|
||||
const events: ScoreEvent[] = [];
|
||||
for (const e of scoreEvents(score)) {
|
||||
const eStart = e.startTick;
|
||||
const eEnd = e.startTick + e.durationTicks;
|
||||
if (eEnd <= startTick || eStart >= endTick) continue;
|
||||
const clippedStart = Math.max(eStart, startTick);
|
||||
const clippedEnd = Math.min(eEnd, endTick);
|
||||
events.push({
|
||||
...e,
|
||||
startTick: clippedStart - startTick,
|
||||
durationTicks: clippedEnd - clippedStart,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...score,
|
||||
id: `${score.id}:${from}+${count}`,
|
||||
measures: measures.map(m => ({ ...m, startTick: m.startTick - startTick })),
|
||||
voices: [{ id: 'melody', events }],
|
||||
};
|
||||
}
|
||||
|
||||
/** True when every event of `score` lies inside the sorted pitch pool. */
|
||||
export function scoreFitsPool(score: Score, pool: number[]): boolean {
|
||||
const set = new Set(pool);
|
||||
return scoreEvents(score).every(e => e.pitch === null || set.has(e.pitch.midi));
|
||||
}
|
||||
Reference in New Issue
Block a user