Phrase mode P0: score model, scales/keys, playable range, melody + scale generators (tested)

This commit is contained in:
2026-09-14 00:44:09 +02:00
parent 6e24b94a91
commit 8e20098102
8 changed files with 1301 additions and 0 deletions

View 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([]);
});
});

View 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 } };
}

View 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
View 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
View 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
View 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
View 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
View 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));
}