From 8e20098102859e262c743520485741ffeb074cc4 Mon Sep 17 00:00:00 2001 From: Jens Mohrmann Date: Mon, 14 Sep 2026 00:44:09 +0200 Subject: [PATCH] Phrase mode P0: score model, scales/keys, playable range, melody + scale generators (tested) --- src/music/generators.test.ts | 190 +++++++++++++++++++++++++++ src/music/melodyGenerator.ts | 215 ++++++++++++++++++++++++++++++ src/music/playableRange.ts | 96 ++++++++++++++ src/music/scaleDrills.ts | 130 ++++++++++++++++++ src/music/scales.test.ts | 113 ++++++++++++++++ src/music/scales.ts | 183 ++++++++++++++++++++++++++ src/score/model.test.ts | 127 ++++++++++++++++++ src/score/model.ts | 247 +++++++++++++++++++++++++++++++++++ 8 files changed, 1301 insertions(+) create mode 100644 src/music/generators.test.ts create mode 100644 src/music/melodyGenerator.ts create mode 100644 src/music/playableRange.ts create mode 100644 src/music/scaleDrills.ts create mode 100644 src/music/scales.test.ts create mode 100644 src/music/scales.ts create mode 100644 src/score/model.test.ts create mode 100644 src/score/model.ts diff --git a/src/music/generators.test.ts b/src/music/generators.test.ts new file mode 100644 index 0000000..9af4139 --- /dev/null +++ b/src/music/generators.test.ts @@ -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([]); + }); +}); diff --git a/src/music/melodyGenerator.ts b/src/music/melodyGenerator.ts new file mode 100644 index 0000000..7ab7b42 --- /dev/null +++ b/src/music/melodyGenerator.ts @@ -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 = (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 } }; +} diff --git a/src/music/playableRange.ts b/src/music/playableRange.ts new file mode 100644 index 0000000..ff6d1f4 --- /dev/null +++ b/src/music/playableRange.ts @@ -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(); + 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'); +} diff --git a/src/music/scaleDrills.ts b/src/music/scaleDrills.ts new file mode 100644 index 0000000..ab58157 --- /dev/null +++ b/src/music/scaleDrills.ts @@ -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 { + 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 }; +} diff --git a/src/music/scales.test.ts b/src/music/scales.test.ts new file mode 100644 index 0000000..45b8017 --- /dev/null +++ b/src/music/scales.test.ts @@ -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 = { + 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([]); + }); +}); diff --git a/src/music/scales.ts b/src/music/scales.ts new file mode 100644 index 0000000..79e5ea5 --- /dev/null +++ b/src/music/scales.ts @@ -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 = { + 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 = { + 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 = { + 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 = { + '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 = { + 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; + /** 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; + 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; +} diff --git a/src/score/model.test.ts b/src/score/model.test.ts new file mode 100644 index 0000000..8d2d96b --- /dev/null +++ b/src/score/model.test.ts @@ -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); + }); +}); diff --git a/src/score/model.ts b/src/score/model.ts new file mode 100644 index 0000000..f9f30aa --- /dev/null +++ b/src/score/model.ts @@ -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 = { 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)); +}