Files
sheetmusictrainer/src/score/model.ts
OpenClaw ffb2776c8c Giuliani studies as own material category; beam-before-draw fix
- NEW material 'giuliani': figures require a real voicing — the generator
  assigns cycle positions (bass root + upper chord tones) to DISTINCT
  strings with valid frets per bar; bars that cannot be voiced are skipped
  (deterministic), clear error when nothing fits. Patterns: p-i-m, p-i-m-a,
  p-a-m-i, a-i-m, p-i-m-a-m-i, p-m-a-m-i-m. Honors the chord pool, string
  restrictions and the tuning; one figure per beat.
- 6/8 meter added (numerator 6) across score model, settings, generators;
  meter selects now offer 4/4, 3/4, 6/8.
- Arpeggio category: Giuliani entries replaced with note-sequence
  broken-chord figures (1-3-2-1, 1-3-2-5(8), 1-5-3-5(8), 1-2-3-5-3) —
  pure pitch sequences, no voicing claims.
- Beam fix (root cause): setBeam now happens BEFORE voice.draw — VexFlow
  decides flag vs beam at draw time; registering after drawing left both
  flag and beam. Beams drawn after notes, flags suppressed correctly.
2026-09-15 02:44:52 +00:00

251 lines
10 KiB
TypeScript

/**
* 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 | 6; readonly denominator: 4 };
readonly key: ScoreKey;
readonly measures: ScoreMeasure[];
/** Exactly one voice in v1. */
readonly voices: ReadonlyArray<{ readonly id: 'melody'; readonly events: ScoreEvent[] }>;
/** Optional chord symbol per bar index (0-based), e.g. "C", "Am", "G7".
* Rendered above the staff; absent = no symbol for that bar. */
readonly chordSymbols?: ReadonlyArray<string | null>;
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));
}