Phrase mode: ABC parser (subagent), library content, renderer, PhraseTrainer component

This commit is contained in:
2026-09-14 00:57:32 +02:00
parent c7f1fa170b
commit 821f88d43a
14 changed files with 2495 additions and 1 deletions

View File

@@ -0,0 +1,427 @@
import { describe, it, expect } from 'vitest';
import { parseAbc, type AbcParseOptions } from './abcParser';
import { PPQ, validateScore, scoreEvents, scoreFitsPool, type Score } from '../score/model';
/** The §G concert-pitch fixture. */
const FIRST_STEPS = [
'X:1',
'T:First steps',
'C:Original exercise',
'M:4/4',
'L:1/4',
'Q:1/4=60',
'K:C',
'C D E F | G2 E C |]',
].join('\n');
/** Full header block (M: 4/4, L: 1/4, K: C by default); body starts at line 3. */
const HEADERS = 'M:4/4\nL:1/4\nK:C';
const tune = (body: string, headers: string = HEADERS) => `X:1\n${headers}\n${body}`;
const tuneWithKey = (key: string) => tune('C D E F |', `M:4/4\nL:1/4\nK:${key}`);
function mustParse(text: string, options?: AbcParseOptions): Score {
const result = parseAbc(text, options);
if (!result.ok) throw new Error(`expected successful parse, got: ${result.error}`);
return result.value;
}
function mustFail(text: string, pattern: RegExp, options?: AbcParseOptions): void {
const result = parseAbc(text, options);
if (result.ok) {
throw new Error(`expected parse failure matching ${pattern}, but parsing succeeded`);
}
expect(result.error).toMatch(pattern);
}
const midis = (score: Score) => scoreEvents(score).map(e => e.pitch?.midi ?? null);
const durations = (score: Score) => scoreEvents(score).map(e => e.durationTicks);
describe('parseAbc — §G concert-pitch fixture', () => {
it('parses the fixture with the exact expected pitches and ticks', () => {
const score = mustParse(FIRST_STEPS);
expect(midis(score)).toEqual([60, 62, 64, 65, 67, 64, 60]);
expect(durations(score)).toEqual([480, 480, 480, 480, 960, 480, 480]);
expect(score.measures).toHaveLength(2);
expect(score.measures.map(m => m.durationTicks)).toEqual([4 * PPQ, 4 * PPQ]);
expect(validateScore(score)).toEqual([]);
});
it('carries full metadata from the headers', () => {
const score = mustParse(FIRST_STEPS);
expect(score.version).toBe(1);
expect(score.id).toBe('1');
expect(score.title).toBe('First steps');
expect(score.meter).toEqual({ numerator: 4, denominator: 4 });
expect(score.key).toEqual({ tonic: 'C', mode: 'major', signature: 'C' });
expect(score.voices).toHaveLength(1);
expect(score.voices[0].id).toBe('melody');
expect(score.source?.composer).toBe('Original exercise');
expect(score.source?.rights).toContain('CC0');
expect(score.source?.rights).toContain('Original exercise');
expect(score.source?.writtenToSoundingSemitones).toBe(0);
});
it('fits the pitch pool of its own notes', () => {
const score = mustParse(FIRST_STEPS);
expect(scoreFitsPool(score, [60, 62, 64, 65, 67])).toBe(true);
expect(scoreFitsPool(score, [60, 62, 64])).toBe(false);
});
});
describe('parseAbc — headers', () => {
it('defaults L to 1/8 when absent', () => {
const score = mustParse('X:1\nM:4/4\nK:C\nC D E F G A B c | C D E F G A B c |');
expect(durations(score)).toEqual(Array(16).fill(240));
expect(score.measures.map(m => m.durationTicks)).toEqual([4 * PPQ, 4 * PPQ]);
});
it('ignores unknown headers, comments and blank lines silently', () => {
const text = [
'X:1',
'% a full-line comment',
'Y:unknown header ignored',
'w:lyrics line ignored',
'',
'M:4/4',
'L:1/4',
'K:C',
'C D E F | G2 E C | % trailing comment',
].join('\n');
const score = mustParse(text);
expect(midis(score)).toEqual([60, 62, 64, 65, 67, 64, 60]);
});
it('uses the Q: header as metadata only (ignored)', () => {
const score = mustParse(`${FIRST_STEPS}\n`);
expect(score).toBeDefined(); // Q: did not break parsing (covered by fixture)
});
it('requires M: and K: before the body', () => {
mustFail('X:1\nK:C\nC D E F |', /M:/);
mustFail('X:1\nM:4/4\nC D E F |', /K:/);
});
it('rejects unsupported meters and unit lengths', () => {
mustFail(tune('C D E F |', 'M:6/8\nL:1/4\nK:C'), /6\/8|meter/i);
mustFail(tune('C D E F |', 'M:C\nL:1/4\nK:C'), /meter/i);
mustFail(tune('C D E F |', 'M:4/4\nL:1/16\nK:C'), /unit|1\/16/i);
});
it('rejects duplicate M:/K: (no changes mid-tune) and a second X:', () => {
mustFail('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\nM:3/4\nC D E |', /M:/);
mustFail('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\nK:G\nC D E F |', /K:/);
mustFail('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\nX:2', /multiple tunes|X:/i);
});
});
describe('parseAbc — key signatures', () => {
it('resolves tonic, mode and parent signature', () => {
expect(mustParse(tuneWithKey('Am')).key).toEqual({ tonic: 'A', mode: 'minor', signature: 'C' });
expect(mustParse(tuneWithKey('Ddor')).key).toEqual({ tonic: 'D', mode: 'dorian', signature: 'C' });
expect(mustParse(tuneWithKey('Gmix')).key).toEqual({ tonic: 'G', mode: 'mixolydian', signature: 'C' });
expect(mustParse(tuneWithKey('Eb')).key).toEqual({ tonic: 'Eb', mode: 'major', signature: 'Eb' });
expect(mustParse(tuneWithKey('F#')).key).toEqual({ tonic: 'F#', mode: 'major', signature: 'F#' });
expect(mustParse(tuneWithKey('Bb')).key).toEqual({ tonic: 'Bb', mode: 'major', signature: 'Bb' });
expect(mustParse(tuneWithKey('_B')).key).toEqual({ tonic: 'Bb', mode: 'major', signature: 'Bb' });
expect(mustParse(tuneWithKey('Gmaj')).key.mode).toBe('major');
expect(mustParse(tuneWithKey('Amin')).key.mode).toBe('minor');
expect(mustParse(tuneWithKey('Eaeo')).key.mode).toBe('minor');
});
it('rejects unknown keys and never falls back to C', () => {
mustFail(tuneWithKey('H'), /unrecognized key/i);
mustFail(tuneWithKey('Fb'), /unrecognized key/i);
mustFail(tuneWithKey(''), /unrecognized key/i);
mustFail(tuneWithKey('Gbmin7'), /unrecognized key/i);
mustFail(tuneWithKey('C##'), /unrecognized key/i);
});
});
describe('parseAbc — notes, octaves, accidentals', () => {
it('applies ABC octave conventions (C = C4 = 60)', () => {
const score = mustParse(tune('C,, C, C c | c\' c\'\' C c |'));
expect(midis(score)).toEqual([36, 48, 60, 72, 84, 96, 60, 72]);
expect(scoreEvents(score)[2].pitch?.step).toBe('C');
expect(scoreEvents(score)[2].pitch?.octave).toBe(4);
});
it('carries accidentals per (letter, octave) within a measure, seeded from the key', () => {
// §F example: K:G, L:1/4 — signature sharpens F; "=F" naturals it, carry
// keeps F natural, "^F" sharpens it again; next bar starts sharp.
const score = mustParse('X:1\nM:4/4\nL:1/4\nK:G\n=F F ^F F | F4 |');
expect(midis(score)).toEqual([65, 65, 66, 66, 66]);
expect(validateScore(score)).toEqual([]);
});
it('keeps accidentals letter- and octave-specific within the measure', () => {
// ^f is F5 (78); c is C5 (72) — the sharp must not affect the C
const score = mustParse(tune('^f c ^f c |'));
expect(midis(score)).toEqual([78, 72, 78, 72]);
});
it('supports doubled accidentals (^^ / __) per §G', () => {
// Note: the task's error-test bullet lists "^^F" as an error, but §G
// explicitly requires doubled accidentals; spec wins, "^^F" is alter +2.
const sharp = mustParse(tune('^^F F F F |'));
expect(sharp.voices[0].events[0].pitch?.alter).toBe(2);
expect(midis(sharp)).toEqual([67, 67, 67, 67]);
const flat = mustParse(tune('__B B B B |'));
expect(flat.voices[0].events[0].pitch?.alter).toBe(-2);
expect(midis(flat)).toEqual([69, 69, 69, 69]);
});
it('rejects triple and conflicting accidental runs', () => {
mustFail(tune('^^^F C D E |'), /unsupported accidental/i);
mustFail(tune('^_F C D E |'), /conflicting accidentals/i);
mustFail(tune('=^F C D E |'), /conflicting accidentals/i);
});
it('rejects stray accidentals and accidentals on rests', () => {
mustFail(tune('^ C D E F |'), /stray accidental/i);
mustFail(tune('C D E ^z |'), /rest/i);
});
});
describe('parseAbc — durations', () => {
it('supports the exact allowed resulting durations with L:1/4', () => {
// eighth 240, quarter 480, dotted quarter 720, half 960, dotted half 1440,
// whole 1920 — every measure exactly full (4/4 = 1920 ticks)
const score = mustParse(tune('A/2 A A3/2 A | A2 A A | A3 A | A4 |'));
expect(durations(score)).toEqual([240, 480, 720, 480, 960, 480, 480, 1440, 480, 1920]);
expect(validateScore(score)).toEqual([]);
});
it('writes dotted quarters with L:1/8 via multiplier 3 ("A3" = 720)', () => {
const score = mustParse('X:1\nM:4/4\nL:1/8\nK:C\nA3 A A A A A |');
expect(durations(score)).toEqual([720, 240, 240, 240, 240, 240]);
});
it('rejects resulting durations outside the supported table', () => {
// "A3/2" with L:1/8 = 360 ticks (dotted eighth) — not in the supported
// table; the §G example calling it a "dotted quarter" is inconsistent
// with the "multiplier × L ticks" rule, which is authoritative here.
mustFail('X:1\nM:4/4\nL:1/8\nK:C\nA3/2 A A A A A A |', /360|unsupported duration/i);
mustFail(tune('C/4 C D E F |'), /120|unsupported duration/i);
mustFail(tune('A6 A B C D |'), /2880|unsupported duration/i);
mustFail(tune('A0 C D E F |'), /zero duration/i);
});
it('supports rests z with the same duration rules', () => {
const score = mustParse(tune('C z D z |'));
expect(midis(score)).toEqual([60, null, 62, null]);
expect(durations(score)).toEqual([480, 480, 480, 480]);
expect(validateScore(score)).toEqual([]);
});
});
describe('parseAbc — ties', () => {
it('merges a tie across a barline into ONE logical event', () => {
// §G: "one eight-quarter-note logical event" (each C4 = 4 quarters,
// sum = 8 quarters = 3840 ticks; the task bullet's "1920" contradicts
// the merge-sum rule and the contract text).
const score = mustParse(tune('C4- | C4 |'));
expect(scoreEvents(score)).toHaveLength(1);
expect(scoreEvents(score)[0].durationTicks).toBe(8 * PPQ);
expect(scoreEvents(score)[0].startTick).toBe(0);
expect(score.measures.map(m => m.durationTicks)).toEqual([4 * PPQ, 4 * PPQ]);
expect(validateScore(score)).toEqual([]);
});
it('merges a tie chain inside one measure', () => {
const score = mustParse('X:1\nM:4/4\nL:1/8\nK:C\nC2- C2- C4 |');
expect(scoreEvents(score)).toHaveLength(1);
expect(scoreEvents(score)[0].durationTicks).toBe(4 * PPQ);
expect(validateScore(score)).toEqual([]);
});
it('rejects dangling ties and ties to rests', () => {
mustFail(tune('C D E F- |'), /dangling tie|no note follows/i);
mustFail(tune('C4-'), /dangling tie|no note follows/i);
mustFail(tune('C2 D2- | z2 F2 |'), /tie must connect two notes|rest/i);
});
it('rejects ties between different sounding pitches', () => {
mustFail(tune('C2 D2- | E2 F2 |'), /different sounding pitches/i);
mustFail(tune('F4- | ^F4 |'), /different sounding pitches/i);
});
it('rejects a doubled tie marker', () => {
mustFail(tune('C2- -C2 D2 E2 |'), /already tied|unexpected "-"/i);
});
});
describe('parseAbc — repeats', () => {
it('expands |: ... :| so the segment sounds twice', () => {
const score = mustParse(tune('C D E F |: G A B c :| C D E F |'));
expect(midis(score)).toEqual([
60, 62, 64, 65, 67, 69, 71, 72, 67, 69, 71, 72, 60, 62, 64, 65,
]);
expect(score.measures).toHaveLength(4);
expect(validateScore(score)).toEqual([]);
});
it('repeats from the beginning of the body for an unmatched ":|"', () => {
const score = mustParse(tune('C D E F :|'));
expect(midis(score)).toEqual([60, 62, 64, 65, 60, 62, 64, 65]);
expect(score.measures).toHaveLength(2);
});
it('treats an unclosed "|:" as repeating to the end', () => {
const score = mustParse(tune('C D E F |: G A B c |'));
expect(midis(score)).toEqual([60, 62, 64, 65, 67, 69, 71, 72, 67, 69, 71, 72]);
expect(score.measures).toHaveLength(3);
});
it('rejects nested repeats and alternate endings', () => {
mustFail(tune('C D E F |: G A B c |: C D E F :|'), /nested repeats/i);
mustFail(tune('C D E F |1 G A B c :|2 C D E F |'), /alternate endings/i);
});
it('supports ||, |] and :|]', () => {
const a = mustParse(tune('C D E F || G A B c |]'));
expect(a.measures).toHaveLength(2);
// ":|]": repeat close + final barline; unmatched close repeats from start
const b = mustParse(tune('C D E F :|]'));
expect(b.measures).toHaveLength(2);
expect(midis(b)).toEqual([60, 62, 64, 65, 60, 62, 64, 65]);
});
});
describe('parseAbc — pickups and measure validation', () => {
it('accepts a pickup complemented by the final measure', () => {
const score = mustParse(tune('G | C D E F | G3 |'));
expect(score.measures).toHaveLength(3);
expect(score.measures.map(m => m.durationTicks)).toEqual([480, 4 * PPQ, 3 * PPQ]);
expect(midis(score)).toEqual([67, 60, 62, 64, 65, 67]); // "G3" = dotted half G4
expect(validateScore(score)).toEqual([]);
});
it('rejects short middle measures with the measure number', () => {
mustFail(tune('C D E F | C D E | C D E F |'), /measure 2/);
});
it('rejects overfull measures', () => {
mustFail(tune('C D E F G | C D E F |'), /overfull|measure 1/);
});
it('rejects a short final measure without a pickup', () => {
mustFail(tune('C D E F | G3 |'), /short|measure 2/);
});
it('rejects a single short measure and a pickup without complement', () => {
mustFail(tune('C D E |'), /measure 1 is short/);
mustFail(tune('G | C D E F | G A B C |'), /not complemented/i);
});
it('supports 3/4 meter end to end', () => {
const score = mustParse('X:1\nM:3/4\nL:1/4\nK:C\nC D E | C D E |');
expect(score.meter.numerator).toBe(3);
expect(score.measures.map(m => m.durationTicks)).toEqual([3 * PPQ, 3 * PPQ]);
expect(validateScore(score)).toEqual([]);
});
});
describe('parseAbc — rejections of unsupported syntax', () => {
it('rejects chords, inline fields, tuplets, grace notes, decorations, lyrics, overlays', () => {
mustFail(tune('[CEG] C D E F |'), /chord|inline field/i);
mustFail(tune('[K:G] C D E F |'), /chord|inline field/i);
mustFail(tune('(3 C D E F |'), /tuplet/i);
mustFail(tune('{g} C D E F |'), /grace/i);
mustFail(tune('!trill! C D E F |'), /decoration/i);
mustFail(tune('+accent+ C D E F |'), /decoration/i);
mustFail(tune('C D "words" E F |'), /lyrics/i);
mustFail(tune('C & D E F G |'), /overlay|unsupported/i);
});
it('rejects unsupported characters and stray tokens', () => {
mustFail(tune('C D E H |'), /unsupported note letter/i);
mustFail(tune('C D E Z |'), /unsupported note letter/i);
mustFail(tune('| 3 C D E F |'), /unsupported/i);
mustFail(tune('C2, D E F |'), /unsupported/i);
});
it('reports line and column for body errors', () => {
const result = parseAbc('X:1\nM:4/4\nL:1/4\nK:C\n[CEG] C D E F |');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/\(line 5, column 1\)/);
expect(result.error).toMatch(/chord|inline field/i);
}
const result2 = parseAbc('X:1\nM:4/4\nL:1/4\nK:C\nC D E F | G [CEG] |\n');
expect(result2.ok).toBe(false);
if (!result2.ok) {
// '[' at column 13 of line 5
expect(result2.error).toMatch(/\(line 5, column 13\)/);
}
});
});
describe('parseAbc — bounds', () => {
it('rejects oversized input before parsing', () => {
mustFail('C '.repeat(200 * 1024), /too large|256/i);
});
it('rejects more than 256 measures', () => {
mustFail(`X:1\nM:4/4\nL:1/4\nK:C\n${'C D E F | '.repeat(300)}`, /too many measures/i);
});
});
describe('parseAbc — writtenToSoundingSemitones', () => {
it('applies an octave transposition while preserving the spelling (§B.6)', () => {
const score = mustParse(tune('E, A, C E |'), { writtenToSoundingSemitones: -12 });
// written E3/A3/C4/E4 (52, 57, 60, 64) sounds an octave lower
expect(midis(score)).toEqual([40, 45, 48, 52]);
expect(scoreEvents(score).map(e => [e.pitch?.step, e.pitch?.octave])).toEqual([
['E', 2], ['A', 2], ['C', 3], ['E', 3],
]);
expect(score.source?.writtenToSoundingSemitones).toBe(-12);
expect(validateScore(score)).toEqual([]);
});
it('rejects non-octave transpositions (spelling cannot be preserved)', () => {
mustFail(tune('C D E F |'), /multiple of 12/i, { writtenToSoundingSemitones: -1 });
});
});
describe('parseAbc — source metadata', () => {
it('collects C, S, Z into the source record', () => {
const text = [
'X:5',
'T:Test tune',
'C:Jane Composer',
'S:Public domain collection',
'Z:Simplified from the original',
'M:3/4',
'L:1/4',
'K:G',
'G B d | G B d |',
].join('\n');
const score = mustParse(text);
expect(score.id).toBe('5');
expect(score.title).toBe('Test tune');
expect(score.source?.composer).toBe('Jane Composer');
expect(score.source?.rights).toContain('Public domain collection');
expect(score.source?.adaptation).toBe('Simplified from the original');
});
it('falls back to a placeholder source when neither S: nor C: is given', () => {
const score = mustParse('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |');
expect(score.source?.rights).toContain('unspecified');
expect(score.id).toBe('1');
});
it('uses the abc-untitled fallback id when X: is absent', () => {
const score = mustParse('M:4/4\nL:1/4\nK:C\nC D E F |');
expect(score.id).toBe('abc-untitled');
expect(score.title).toBe('Untitled');
});
it('rejects an empty body (headers only) and rest-only tunes', () => {
mustFail('X:1\nM:4/4\nL:1/4\nK:C\n', /no notes|empty body/i);
mustFail(tune('z z z z |'), /only rests|no notes/i);
});
it('rejects a repeat close before any music', () => {
mustFail(tune(':| C D E F |'), /no music before it/i);
});
});

671
src/exercises/abcParser.ts Normal file
View File

@@ -0,0 +1,671 @@
/**
* ABC-notation subset parser for the Sheet Music Trainer's Phrase Mode.
*
* Implements the subset specified in docs/PHRASE_MODE_IMPLEMENTATION.md §G and
* produces the normalized Score model from src/score/model.ts (§B). The parser
* is pure: no globals, no network, no eval, no I/O.
*
* Supported subset:
* - Headers X, T, C, S, Z, M, L, Q, K; blank lines and %-comments are ignored;
* unknown `word:` headers are ignored silently. M: and K: are required
* before the tune body; L: defaults to 1/8; X is optional.
* - M: exactly 3/4 or 4/4; L: exactly 1/4 or 1/8.
* - K: tonic letter A-G with an optional accidental (prefix ^/_/^^/__, or
* suffix #/b) and an optional mode suffix (none/maj = major, m/min = minor,
* or ion/dor/phr/lyd/mix/aeo/loc). Unknown keys are rejected — never a
* silent fallback to C.
* - Notes a-g/A-G with octave marks (',' down, "'" up), accidentals, and
* duration multipliers (integer, /n, n/m — dots are written as multipliers)
* relative to L. Supported resulting durations ONLY: 240 (eighth), 480
* (quarter), 720 (dotted quarter), 960 (half), 1440 (dotted half), 1920
* (whole) ticks at PPQ=480.
* - Rests z with the same duration rules -> events with pitch null.
* - Ties '-' between two notes of the SAME sounding pitch (also across
* barlines), merged into ONE ScoreEvent whose duration is the sum.
* - Barlines '|', '||', '|]'; simple non-nested '|: ... :|' repeats play the
* segment twice (expanded); an unmatched ':|' repeats from the beginning of
* the body; an unclosed '|:' repeats to the end (standard ABC behaviour).
* - Accidental carry per (letter, octave) within each measure, seeded at each
* new measure from the key signature (all octaves), reset at every barline.
* - Measure validation: every measure full (numerator × 480), EXCEPT a short
* first pickup measure which must be complemented by the final measure
* (pickup + final = full). Short middle / overfull measures are errors.
*
* Deliberate readings of spec ambiguities (see the task handoff notes):
* - Durations are standard ABC: multiplier × L ticks. The §G example
* "A3/2 with L:1/8 = dotted quarter" is arithmetically inconsistent with
* that rule (1.5 × 240 = 360 ticks, a dotted eighth, which is NOT in the
* supported table); with L:1/4, A3/2 IS the dotted quarter (720), and with
* L:1/8, A3 is the dotted quarter. The absolute supported-ticks table is
* authoritative.
* - Doubled accidentals ^^/__ are SUPPORTED (§G explicitly requires "doubled
* accidentals"); "^^F" is therefore valid despite one bullet in the task's
* error-test list. Triple or conflicting accidental runs are rejected.
* - writtenToSoundingSemitones is ADDED to the written midi (§B.6: -12 turns
* written E3/52 into sounding E2/40 while keeping the spelling); the task
* text's word "subtract" would flip the sign for the same example value.
* - "C4- | C4 |" (L:1/4) merges into ONE event of 3840 ticks — eight quarter
* notes, per §G ("one eight-quarter-note logical event") and the merge-sum
* rule; 1920 ticks would contradict both.
*/
import { PPQ, LETTER_PC, STEP_LETTERS, validateScore } from '../score/model';
import type {
Result,
Score,
ScoreMeasure,
SpelledPitch,
StepLetter,
} from '../score/model';
import { TONICS, keyFor, spelledPitch } from '../music/scales';
import type { KeyContext, ModeId } from '../music/scales';
export interface AbcParseOptions {
/**
* Semitones from WRITTEN source pitch to sounding pitch (§B.6), e.g. -12
* for written guitar/bass material. The parser adds this value to the
* written midi while keeping the spelling (recomputing the octave).
* Must be an integer multiple of 12 in this subset. Default 0 (concert
* pitch).
*/
writtenToSoundingSemitones?: number;
}
/** Input/measure/event bounds (§G), enforced during parsing, not after. */
const MAX_INPUT_CHARS = 256 * 1024;
const MAX_MEASURES = 256;
const MAX_EVENTS = 4096;
/** The only note lengths the subset supports (ticks at PPQ = 480). */
const SUPPORTED_DURATION_TICKS: readonly number[] = [240, 480, 720, 960, 1440, 1920];
interface Pos {
readonly line: number;
readonly col: number;
}
/** A parse failure; when no position applies the message stands alone. */
const fail = (message: string, pos?: Pos): { ok: false; error: string } => ({
ok: false,
error: pos ? `${message} (line ${pos.line}, column ${pos.col})` : message,
});
/** One note or rest as written in the body (pitch already resolved). */
interface NoteSpec {
readonly isRest: boolean;
readonly pitch: SpelledPitch | null;
readonly durationTicks: number;
/** True when a '-' tie follows this note (checked/merged at build time). */
tieToNext: boolean;
readonly pos: Pos;
}
/** Names for rejected syntax, used in error messages. */
const UNSUPPORTED_NOTATION: Record<string, string> = {
'[': 'chords and inline fields (e.g. [CEG] or [K:...])',
'(': 'tuplets and slurs (e.g. (3)',
'{': 'grace notes',
'"': 'lyrics and annotations',
'!': 'decorations (e.g. !trill!)',
'+': 'decorations (e.g. +accent+)',
'&': 'voice overlays',
};
const describePitch = (p: SpelledPitch): string => {
const acc = p.alter === 2 ? '##' : p.alter === 1 ? '#' : p.alter === -2 ? 'bb' : p.alter === -1 ? 'b' : '';
return `${p.step}${acc}${p.octave}`;
};
export function parseAbc(text: string, options: AbcParseOptions = {}): Result<Score> {
const shift = options.writtenToSoundingSemitones ?? 0;
if (!Number.isInteger(shift) || ((shift % 12) + 12) % 12 !== 0) {
return fail(
`writtenToSoundingSemitones must be an integer multiple of 12 (spelling-preserving octave shift), got ${shift}`,
);
}
if (text.length > MAX_INPUT_CHARS) {
return fail(`ABC input is too large (${text.length} characters; the limit is 256 KiB)`);
}
// ---------------------------------------------------------------- headers
const h = {
x: null as string | null,
t: null as string | null,
c: null as string | null,
s: null as string | null,
z: null as string | null,
q: null as string | null,
meterNumerator: 0 as 0 | 3 | 4,
unitTicks: 240, // default L:1/8
key: null as KeyContext | null,
seen: new Set<string>(),
};
const parseKeyHeader = (value: string, pos: Pos): { ok: false; error: string } | null => {
const spec = value.trim();
let i = 0;
let prefixAlter = 0;
while (i < spec.length && (spec[i] === '^' || spec[i] === '_')) {
prefixAlter += spec[i] === '^' ? 1 : -1;
i++;
}
if (i >= spec.length || !/^[A-G]$/.test(spec[i])) {
return fail(
`unrecognized key "${value}" — expected a tonic letter A-G with an optional accidental and mode suffix (e.g. K:G, K:F#, K:Am, K:Ddor, K:Eb)`,
pos,
);
}
const letter = spec[i].toUpperCase();
i++;
let suffixAlter = 0;
while (i < spec.length && (spec[i] === '#' || spec[i] === 'b')) {
suffixAlter += spec[i] === '#' ? 1 : -1;
i++;
}
if (prefixAlter !== 0 && suffixAlter !== 0) {
return fail(`unrecognized key "${value}" — mixing prefix and suffix accidentals`, pos);
}
const totalAlter = prefixAlter + suffixAlter;
if (Math.abs(totalAlter) > 1) {
return fail(`unrecognized key "${value}" — double accidentals are not supported in key signatures`, pos);
}
const modeWord = spec.slice(i).trim().toLowerCase();
const MODE_MAP: Record<string, ModeId> = {
'': 'major', maj: 'major', ion: 'major',
m: 'minor', min: 'minor', aeo: 'minor',
dor: 'dorian', phr: 'phrygian', lyd: 'lydian',
mix: 'mixolydian', loc: 'locrian',
};
const mode = MODE_MAP[modeWord];
if (!mode) {
return fail(
`unrecognized key "${value}" — unknown mode suffix "${modeWord}" (supported: none, maj, m, min, ion, dor, phr, lyd, mix, aeo, loc)`,
pos,
);
}
const tonicName = totalAlter === 1 ? `${letter}#` : totalAlter === -1 ? `${letter}b` : letter;
if (!(tonicName in TONICS)) {
return fail(`unrecognized key "${value}" — no conventional signature for tonic "${tonicName}"`, pos);
}
h.key = keyFor(tonicName, mode);
return null;
};
const handleHeader = (letter: string, value: string, pos: Pos): { ok: false; error: string } | null => {
switch (letter) {
case 'x':
if (h.seen.has('x')) {
return fail('multiple tunes are not supported (second "X:" header)', pos);
}
h.seen.add('x');
h.x = value;
return null;
case 'm': {
if (h.seen.has('m')) {
return fail('duplicate "M:" header (meter changes mid-tune are not supported)', pos);
}
h.seen.add('m');
const match = /^([34])\/4$/.exec(value);
if (!match) {
return fail(`unsupported meter "${value || '(empty)'}" — only M:3/4 and M:4/4 are supported`, pos);
}
h.meterNumerator = Number(match[1]) as 3 | 4;
return null;
}
case 'l': {
if (h.seen.has('l')) return fail('duplicate "L:" header', pos);
h.seen.add('l');
const match = /^1\/([48])$/.exec(value);
if (!match) {
return fail(`unsupported unit length "${value || '(empty)'}" — only L:1/4 and L:1/8 are supported`, pos);
}
h.unitTicks = Number(match[1]) === 4 ? PPQ : PPQ / 2;
return null;
}
case 'k': {
if (h.seen.has('k')) {
return fail('duplicate "K:" header (key changes mid-tune are not supported)', pos);
}
h.seen.add('k');
return parseKeyHeader(value, pos);
}
case 't':
case 'c':
case 's':
case 'z':
case 'q': {
// Metadata: the first occurrence wins, later ones are ignored.
if (!h.seen.has(letter)) {
h.seen.add(letter);
h[letter] = value;
}
return null;
}
default:
return null; // unknown header: ignored silently (§G)
}
};
// ------------------------------------------------------------------ body
/** Accidental carry per (letter, octave) within the current measure. */
let carry = new Map<string, number>();
let cur: NoteSpec[] = [];
const measures: NoteSpec[][] = [];
let repeatStart: number | null = null;
let bodySeen = false;
const pushMeasure = (pos: Pos): { ok: false; error: string } | null => {
if (cur.length === 0) return null; // redundant/leading barline: no measure
if (measures.length >= MAX_MEASURES) {
return fail(`too many measures (limit ${MAX_MEASURES})`, pos);
}
measures.push(cur);
cur = [];
carry = new Map(); // accidental carry resets at every barline
return null;
};
const closeRepeat = (pos: Pos): { ok: false; error: string } | null => {
const pushed = pushMeasure(pos);
if (pushed) return pushed;
const start = repeatStart ?? 0; // unmatched ":|" repeats from the body start
const segment = measures.slice(start);
if (segment.length === 0) return fail('empty repeat section', pos);
for (const segmentMeasure of segment) {
if (measures.length >= MAX_MEASURES) {
return fail(`too many measures after repeat expansion (limit ${MAX_MEASURES})`, pos);
}
measures.push(segmentMeasure);
}
repeatStart = null;
return null;
};
const parseDuration = (
raw: string,
start: number,
pos: Pos,
): { ok: true; nextIndex: number; ticks: number } | { ok: false; error: string } => {
let i = start;
const n = raw.length;
let numStr = '';
while (i < n && raw[i] >= '0' && raw[i] <= '9') {
numStr += raw[i];
i++;
}
let hasSlash = false;
let denStr = '';
if (i < n && raw[i] === '/') {
hasSlash = true;
i++;
while (i < n && raw[i] >= '0' && raw[i] <= '9') {
denStr += raw[i];
i++;
}
}
if (numStr.length === 0 && !hasSlash) {
return { ok: true, nextIndex: start, ticks: h.unitTicks };
}
if (numStr.length > 6 || denStr.length > 6) {
return fail(`duration "${raw.slice(start, i)}" is out of range`, pos);
}
const num = numStr.length > 0 ? Number(numStr) : 1;
const den = hasSlash ? (denStr.length > 0 ? Number(denStr) : 2) : 1;
if (num === 0) return fail(`zero duration "${raw.slice(start, i)}"`, pos);
const product = num * h.unitTicks;
if (product % den !== 0) {
return fail(`unsupported duration "${raw.slice(start, i)}" — it does not resolve to whole ticks`, pos);
}
const ticks = product / den;
if (!SUPPORTED_DURATION_TICKS.includes(ticks)) {
return fail(
`unsupported duration "${raw.slice(start, i)}" = ${ticks} ticks — supported: eighth 240, quarter 480, dotted quarter 720, half 960, dotted half 1440, whole 1920`,
pos,
);
}
return { ok: true, nextIndex: i, ticks };
};
const parseNoteToken = (
raw: string,
start: number,
line: number,
key: KeyContext,
): { ok: true; nextIndex: number } | { ok: false; error: string } => {
let i = start;
const n = raw.length;
const pos = (): Pos => ({ line, col: start + 1 });
// Optional accidental prefix: ^ ^^ __ _ = (no mixing of kinds).
let explicitAlter: number | null = null;
if (raw[i] === '^' || raw[i] === '_' || raw[i] === '=') {
let sharps = 0;
let flats = 0;
let naturals = 0;
while (i < n && (raw[i] === '^' || raw[i] === '_' || raw[i] === '=')) {
if (raw[i] === '^') sharps++;
else if (raw[i] === '_') flats++;
else naturals++;
i++;
}
if (naturals > 0 && (sharps > 0 || flats > 0)) {
return fail('conflicting accidentals — "=" cannot be combined with "^" or "_"', pos());
}
if (sharps > 0 && flats > 0) {
return fail('conflicting accidentals — "^" and "_" cannot be combined', pos());
}
if (naturals > 1) return fail('repeated "=" accidental', pos());
if (sharps > 2 || flats > 2) {
return fail('unsupported accidental — at most doubled ("^^" or "__")', pos());
}
explicitAlter = sharps - flats;
}
if (i >= n || !/[a-zA-Z]/.test(raw[i])) {
if (explicitAlter !== null) {
return fail('stray accidental — expected a note letter after the accidental', pos());
}
return fail(`unexpected character "${raw[i] ?? ''}"`, pos());
}
const ch = raw[i];
if (ch === 'z') {
if (explicitAlter !== null) {
return fail('accidentals apply to notes, not rests', pos());
}
const dur = parseDuration(raw, i + 1, pos());
if (!dur.ok) return dur;
cur.push({ isRest: true, pitch: null, durationTicks: dur.ticks, tieToNext: false, pos: pos() });
return { ok: true, nextIndex: dur.nextIndex };
}
if (!/^[a-gA-G]$/.test(ch)) {
return fail(`unsupported note letter "${ch}" — only A-G/a-g (and rests z) are supported`, pos());
}
const isLower = ch >= 'a' && ch <= 'g';
const letter = (isLower ? ch.toUpperCase() : ch) as StepLetter;
let octave = isLower ? 5 : 4; // ABC: C = C4 (midi 60), c = C5 (72)
i++;
while (i < n && (raw[i] === ',' || raw[i] === "'")) {
octave += raw[i] === ',' ? -1 : 1;
i++;
}
const dur = parseDuration(raw, i, { line, col: i + 1 });
if (!dur.ok) return dur;
// Accidental carry: explicit accidental wins, else this measure's
// carried value for (letter, octave), else the key signature.
const carryKey = `${letter}${octave}`;
const alter = explicitAlter ?? carry.get(carryKey) ?? key.signatureAlter[letter];
const writtenMidi = 12 * (octave + 1) + LETTER_PC[STEP_LETTERS.indexOf(letter)] + alter;
const soundingMidi = writtenMidi + shift;
if (soundingMidi < 0 || soundingMidi > 127) {
return fail(`note "${raw.slice(start, i)}" resolves to MIDI ${soundingMidi}, outside the range 0-127`, pos());
}
let pitch: SpelledPitch;
try {
pitch = spelledPitch(soundingMidi, letter, alter);
} catch {
return fail(`internal spelling error for "${raw.slice(start, i)}"`, pos());
}
if (explicitAlter !== null) carry.set(carryKey, explicitAlter);
cur.push({ isRest: false, pitch, durationTicks: dur.ticks, tieToNext: false, pos: pos() });
return { ok: true, nextIndex: dur.nextIndex };
};
const tokenizeLine = (raw: string, line: number): { ok: false; error: string } | null => {
let i = 0;
const n = raw.length;
while (i < n) {
const ch = raw[i];
const col = i + 1;
if (ch === ' ' || ch === '\t') {
i++;
continue;
}
if (ch === '%') break; // comment to end of line
if (!bodySeen) {
if (h.meterNumerator === 0) {
return fail('missing "M:" header before the tune body', { line, col });
}
if (!h.key) {
return fail('missing "K:" header before the tune body', { line, col });
}
bodySeen = true;
}
const here: Pos = { line, col };
if (ch === '|') {
const next = raw[i + 1] ?? '';
if (next === '|' || next === ']') {
const pushed = pushMeasure(here);
if (pushed) return pushed;
i += 2;
continue;
}
if (next === ':') {
if (repeatStart !== null) {
return fail('nested repeats are not supported', here);
}
const pushed = pushMeasure(here);
if (pushed) return pushed;
repeatStart = measures.length;
i += 2;
continue;
}
if (next >= '0' && next <= '9') {
return fail('alternate endings (|1, |2) are not supported', here);
}
const pushed = pushMeasure(here);
if (pushed) return pushed;
i++;
continue;
}
if (ch === ':') {
if (raw[i + 1] === '|') {
let j = i + 2;
if (raw[j] === ']') j++; // ":|]" = repeat close + final barline
if (repeatStart === null && measures.length === 0 && cur.length === 0) {
return fail('repeat close ":|" with no music before it', here);
}
const closed = closeRepeat(here);
if (closed) return closed;
i = j;
continue;
}
return fail('unexpected ":" — only ":|" repeat closes are supported', here);
}
if (ch === '-') {
const last = cur.length > 0 ? cur[cur.length - 1] : null;
if (!last || last.isRest) {
return fail('unexpected "-" — ties connect two notes', here);
}
if (last.tieToNext) {
return fail('unexpected "-" — this note is already tied', here);
}
last.tieToNext = true;
i++;
continue;
}
if (/[a-gA-Gz^_=]/.test(ch)) {
const key = h.key; // non-null: bodySeen was checked above
if (!key) return fail('missing "K:" header before the tune body', here);
const parsed = parseNoteToken(raw, i, line, key);
if (!parsed.ok) return parsed;
i = parsed.nextIndex;
continue;
}
const named = UNSUPPORTED_NOTATION[ch];
if (named) {
return fail(`unsupported syntax "${ch}" — ${named} are not supported in this subset`, here);
}
if (/^[a-zA-Z]$/.test(ch)) {
return fail(`unsupported note letter "${ch}" — only A-G/a-g (and rests z) are supported`, here);
}
return fail(`unsupported syntax "${ch}" — this character is not supported in this subset`, here);
}
return null;
};
const lines = text.split(/\r?\n/);
for (let li = 0; li < lines.length; li++) {
const raw = lines[li];
const line = li + 1;
const trimmed = raw.trim();
if (trimmed.length === 0 || trimmed.startsWith('%')) continue; // blank/comment
const headerMatch = /^\s*([A-Za-z]+):(.*)$/.exec(raw);
if (headerMatch) {
const letter = headerMatch[1].toLowerCase();
const value = headerMatch[2].trim();
const pos: Pos = { line, col: raw.indexOf(':') + 2 };
const err = handleHeader(letter, value, pos);
if (err) return err;
continue;
}
const err = tokenizeLine(raw, line);
if (err) return err;
}
// ------------------------------------------------- finalize / expansion
const endPos: Pos = { line: lines.length, col: 1 };
if (repeatStart !== null) {
// Unclosed "|:" repeats to the end of the tune (standard ABC).
const pushed = pushMeasure(endPos);
if (pushed) return pushed;
const segment = measures.slice(repeatStart);
if (segment.length === 0) return fail('empty repeat section');
for (const segmentMeasure of segment) {
if (measures.length >= MAX_MEASURES) {
return fail(`too many measures after repeat expansion (limit ${MAX_MEASURES})`, endPos);
}
measures.push(segmentMeasure);
}
repeatStart = null;
} else {
const pushed = pushMeasure(endPos);
if (pushed) return pushed;
}
if (measures.length === 0) {
return fail('the tune contains no notes (empty body)');
}
// ------------------------------------------------------ measure checks
const numMeasures = measures.length;
const full = h.meterNumerator * PPQ;
const sums = measures.map(ms => ms.reduce((total, note) => total + note.durationTicks, 0));
for (let m = 0; m < numMeasures; m++) {
if (sums[m] > full) {
return fail(
`measure ${m + 1} is overfull: ${sums[m]} ticks (expected ${full} for ${h.meterNumerator}/4)`,
);
}
}
if (numMeasures === 1) {
if (sums[0] !== full) {
return fail(`measure 1 is short: ${sums[0]} ticks (expected ${full} for ${h.meterNumerator}/4)`);
}
} else {
for (let m = 1; m < numMeasures; m++) {
const mayComplement = m === numMeasures - 1 && sums[0] < full;
if (sums[m] < full && !mayComplement) {
return fail(
`measure ${m + 1} is short: ${sums[m]} ticks (expected ${full} for ${h.meterNumerator}/4)`,
);
}
}
if (sums[0] < full && sums[0] + sums[numMeasures - 1] !== full) {
return fail(
`pickup measure is not complemented: measure 1 (${sums[0]}) + final measure (${sums[numMeasures - 1]}) != ${full} ticks`,
);
}
}
// ------------------------------------------------------- event building
interface MutableEvent {
id: string;
startTick: number;
durationTicks: number;
pitch: SpelledPitch | null;
}
const events: MutableEvent[] = [];
let tick = 0;
let eventSeq = 0;
let tieOpen: NoteSpec | null = null;
for (let m = 0; m < numMeasures; m++) {
for (const note of measures[m]) {
if (tieOpen) {
if (!note.pitch || !tieOpen.pitch) {
return fail('a tie must connect two notes, but a rest follows the tied note', note.pos);
}
if (tieOpen.pitch.midi !== note.pitch.midi) {
return fail(
`tie connects different sounding pitches (${describePitch(tieOpen.pitch)} != ${describePitch(note.pitch)})`,
note.pos,
);
}
const merged = events[events.length - 1];
merged.durationTicks += note.durationTicks;
tieOpen = note.tieToNext ? note : null;
} else {
if (events.length >= MAX_EVENTS) {
return fail(`too many events (limit ${MAX_EVENTS})`, note.pos);
}
eventSeq++;
events.push({
id: `ev${eventSeq}`,
startTick: tick,
durationTicks: note.durationTicks,
pitch: note.pitch,
});
if (note.tieToNext) tieOpen = note;
}
tick += note.durationTicks;
}
}
if (tieOpen) {
return fail('dangling tie: no note follows the tied note', tieOpen.pos);
}
if (events.every(e => e.pitch === null)) {
return fail('the tune contains no notes (only rests)');
}
const outMeasures: ScoreMeasure[] = [];
let measureStart = 0;
for (let m = 0; m < numMeasures; m++) {
outMeasures.push({ number: m + 1, startTick: measureStart, durationTicks: sums[m] });
measureStart += sums[m];
}
if (!h.key) {
// Unreachable when measures exist (the body cannot start without K:),
// but keeps the type checker honest.
return fail('missing "K:" header before the tune body');
}
const score: Score = {
version: 1,
id: h.x !== null && h.x.length > 0 ? h.x : 'abc-untitled',
title: h.t !== null && h.t.length > 0 ? h.t : 'Untitled',
meter: { numerator: h.meterNumerator as 3 | 4, denominator: 4 },
key: { tonic: h.key.tonic, mode: h.key.mode, signature: h.key.signature },
measures: outMeasures,
voices: [{ id: 'melody', events }],
source: {
rights: `Transcription by project contributor (CC0); source: ${h.s || h.c || 'unspecified'}`,
writtenToSoundingSemitones: shift,
...(h.c ? { composer: h.c } : {}),
...(h.z ? { adaptation: h.z } : {}),
},
};
const problems = validateScore(score);
if (problems.length > 0) {
return fail(`parsed tune failed score validation: ${problems.join('; ')}`);
}
return { ok: true, value: score };
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { EXERCISES, findExercise, loadExercise } from './library';
import { validateScore, scoreEvents } from '../score/model';
describe('bundled exercise library', () => {
it('has a non-empty, uniquely-id\'d catalog', () => {
expect(EXERCISES.length).toBeGreaterThanOrEqual(4);
const ids = new Set(EXERCISES.map(e => e.id));
expect(ids.size).toBe(EXERCISES.length);
for (const ex of EXERCISES) {
expect(ex.label.length).toBeGreaterThan(0);
expect(ex.abc.length).toBeGreaterThan(0);
}
});
it('every bundled file parses and validates', () => {
for (const ex of EXERCISES) {
const res = loadExercise(ex.id);
expect(res.ok, `${ex.id}: ${res.ok ? '' : res.error}`).toBe(true);
if (!res.ok) continue;
const problems = validateScore(res.value);
expect(problems, `${ex.id}: ${problems.join('; ')}`).toEqual([]);
expect(scoreEvents(res.value).length).toBeGreaterThan(0);
expect(res.value.title.length).toBeGreaterThan(0);
expect(res.value.source).toBeDefined();
expect(res.value.source!.rights.length).toBeGreaterThan(0);
}
});
it('exercise sources declare provenance (composer or original)', () => {
for (const ex of EXERCISES) {
const res = loadExercise(ex.id);
if (!res.ok) continue;
const src = res.value.source!;
expect(src.composer || '').toBeTruthy();
expect(src.rights).toBeTruthy();
}
});
it('Ode to Joy theme has the expected note count and midis', () => {
const res = loadExercise('ode-to-joy');
expect(res.ok).toBe(true);
if (!res.ok) return;
const evts = scoreEvents(res.value);
expect(evts).toHaveLength(30); // two 4-bar phrases
expect(res.value.measures).toHaveLength(8);
// First phrase: E E F G G F E D C C D E
expect(evts.slice(0, 12).map(e => e.pitch!.midi)).toEqual([64, 64, 65, 67, 67, 65, 64, 62, 60, 60, 62, 64]);
// Bar 4 dotted rhythm: E3/2 D/2 D2 → 720, 240, 960
expect(evts[12].durationTicks).toBe(720);
expect(evts[13].durationTicks).toBe(240);
expect(evts[14].durationTicks).toBe(960);
});
it('unknown exercise ids fail cleanly', () => {
expect(loadExercise('nope').ok).toBe(false);
expect(findExercise('nope')).toBeUndefined();
expect(findExercise('ode-to-joy')?.id).toBe('ode-to-joy');
});
});

54
src/exercises/library.ts Normal file
View File

@@ -0,0 +1,54 @@
/**
* Bundled exercise library (contract G).
*
* Files live in src/exercises/library/*.abc and are parsed at runtime by the
* ABC subset parser. Every file is validated by a unit test. Provenance and
* adaptation notes are embedded in the files themselves and mirrored in
* SOURCES.md. Content policy: verified public-domain melodies + original
* technical exercises only — nothing is attributed to a historical composer
* without a verifiable source (see docs/PHRASE_MODE_IMPLEMENTATION.md).
*/
import { parseAbc, type AbcParseOptions } from './abcParser';
import type { Result } from '../score/model';
import type { Score } from '../score/model';
import odeRaw from './library/ode-to-joy.abc?raw';
import twinkleRaw from './library/twinkle.abc?raw';
import firstPositionRaw from './library/first-position-study.abc?raw';
import eMinorRaw from './library/e-minor-study.abc?raw';
import dottedRaw from './library/dotted-rhythm-study.abc?raw';
import brokenChordRaw from './library/broken-chord-study.abc?raw';
export interface ExerciseInfo {
readonly id: string;
readonly label: string;
/** Difficulty hint shown in the picker. */
readonly level: 'beginner' | 'intermediate';
readonly abc: string;
}
export const EXERCISES: ExerciseInfo[] = [
{ id: 'ode-to-joy', label: 'Ode to Joy (Theme)', level: 'beginner', abc: odeRaw },
{ id: 'twinkle', label: 'Ah! vous dirai-je, maman', level: 'beginner', abc: twinkleRaw },
{ id: 'first-position-study', label: 'First Position Study', level: 'beginner', abc: firstPositionRaw },
{ id: 'e-minor-study', label: 'E Minor Study', level: 'intermediate', abc: eMinorRaw },
{ id: 'dotted-rhythm-study', label: 'Dotted Rhythm Study', level: 'intermediate', abc: dottedRaw },
{ id: 'broken-chord-study', label: 'Broken Chord Study', level: 'intermediate', abc: brokenChordRaw },
];
export function findExercise(id: string): ExerciseInfo | undefined {
return EXERCISES.find(e => e.id === id);
}
const cache = new Map<string, Result<Score>>();
/** Parse a bundled exercise (memoized; failures are cached too). */
export function loadExercise(id: string, options?: AbcParseOptions): Result<Score> {
const key = id + (options?.writtenToSoundingSemitones ?? 0);
const hit = cache.get(key);
if (hit) return hit;
const info = findExercise(id);
const result = info ? parseAbc(info.abc, options) : { ok: false as const, error: `Unknown exercise: ${id}` };
cache.set(key, result);
return result;
}

View File

@@ -0,0 +1,11 @@
% Original technical exercise: broken C-G-Am-F chords as a single line.
% Written for this project, 2026, CC0.
X:6
T:Broken Chord Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:C
C E G E | C E G c | G, B, D B, | G, B, D G | A, C E C | A, C E A | F, A, C A, | F, A, C F |]

View File

@@ -0,0 +1,11 @@
% Original technical exercise: dotted quarter / eighth patterns.
% Written for this project, 2026, CC0.
X:5
T:Dotted Rhythm Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=70
K:C
C3/2 E/2 E F | G3/2 E/2 C2 | D3/2 E/2 F G | E3/2 D/2 C2 | C3/2 E/2 E F | G3/2 E/2 G2 | A3/2 G/2 F E | D3/2 C/2 C2 |]

View File

@@ -0,0 +1,11 @@
% Original technical exercise (not attributed to any historical composer).
% Written for this project, 2026, CC0. E minor + D major arpeggios.
X:4
T:E Minor Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:Em
E, G, B, E | G B E2 | E, G, B, E | G B E2 | D F A D | F A D2 | E, G, B, E | E, B, E2 |]

View File

@@ -0,0 +1,11 @@
% Original technical exercise (not attributed to any historical composer).
% Written for this project, 2026, CC0. G major, first-position friendly.
X:3
T:First Position Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:G
G A B D | D B A G | E G A B | A G G2 | D E G A | B A G E | D E G A | G D G2 |]

View File

@@ -0,0 +1,15 @@
% Ode to Joy — main theme of Beethoven's Symphony No. 9 (1824).
% Composer died 1827; the work itself is public domain worldwide.
% Transcription: project contributor, 2026 (CC0). Single-voice adaptation:
% only the lead melody line; the accompanying harmonies are omitted.
% Pitch convention: concert pitch.
X:1
T:Ode to Joy (Theme)
C:Ludwig van Beethoven (1770-1827)
S:Symphony No. 9 in D minor, Op. 125 (1824), fourth movement main theme
Z:Project contributor, 2026 (CC0 transcription); adaptation: melody line only
M:4/4
L:1/4
Q:1/4=100
K:C
E E F G | G F E D | C C D E | E3/2 D/2 D2 | E E F G | G F E D | C C D E | D3/2 C/2 C2 |]

View File

@@ -0,0 +1,13 @@
% Ah! vous dirai-je, maman — traditional French melody (published 1761).
% Traditional tune: public domain. Transcription: project contributor, 2026 (CC0).
% Pitch convention: concert pitch.
X:2
T:Ah! vous dirai-je, maman (Twinkle, Twinkle)
C:Traditional (French melody, published 1761)
S:Common version of the tune; no known composer
Z:Project contributor, 2026 (CC0 transcription)
M:4/4
L:1/4
Q:1/4=100
K:C
C C G G | A A G2 | F F E E | D D C2 | G G F F | E E D2 | G G F F | E E D2 | C C G G | A A G2 | F F E E | D D C2 |]