@k-l-lambda/lilylet 0.1.62 → 0.1.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/abc/grammar.jison.js +300 -187
- package/lib/lilylet/abcDecoder.js +40 -12
- package/lib/lilylet/lilypondEncoder.js +3 -0
- package/lib/lilylet/meiEncoder.js +87 -48
- package/lib/source/abc/abc.d.ts +102 -0
- package/lib/source/abc/abc.js +25 -0
- package/lib/source/abc/parser.d.ts +3 -0
- package/lib/source/abc/parser.js +6 -0
- package/lib/source/lilylet/abcDecoder.d.ts +25 -0
- package/lib/source/lilylet/abcDecoder.js +1035 -0
- package/lib/source/lilylet/index.d.ts +10 -0
- package/lib/source/lilylet/index.js +10 -0
- package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/source/lilylet/lilypondDecoder.js +1223 -0
- package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/source/lilylet/lilypondEncoder.js +893 -0
- package/lib/source/lilylet/meiEncoder.d.ts +8 -0
- package/lib/source/lilylet/meiEncoder.js +1985 -0
- package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/source/lilylet/musicXmlEncoder.js +701 -0
- package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/source/lilylet/musicXmlTypes.js +7 -0
- package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/source/lilylet/musicXmlUtils.js +469 -0
- package/lib/source/lilylet/parser.d.ts +14 -0
- package/lib/source/lilylet/parser.js +161 -0
- package/lib/source/lilylet/serializer.d.ts +11 -0
- package/lib/source/lilylet/serializer.js +791 -0
- package/lib/source/lilylet/types.d.ts +253 -0
- package/lib/source/lilylet/types.js +100 -0
- package/lib/tests/abc-abcjs-parse.d.ts +8 -0
- package/lib/tests/abc-abcjs-parse.js +90 -0
- package/lib/tests/abc-abcjs-svg.d.ts +1 -0
- package/lib/tests/abc-abcjs-svg.js +143 -0
- package/lib/tests/abc-decoder.d.ts +1 -0
- package/lib/tests/abc-decoder.js +67 -0
- package/lib/tests/abc-mei-compare.d.ts +1 -0
- package/lib/tests/abc-mei-compare.js +525 -0
- package/lib/tests/auto-beam.d.ts +9 -0
- package/lib/tests/auto-beam.js +151 -0
- package/lib/tests/computeMeiHashes.d.ts +1 -0
- package/lib/tests/computeMeiHashes.js +87 -0
- package/lib/tests/encoder-mutation.d.ts +9 -0
- package/lib/tests/encoder-mutation.js +110 -0
- package/lib/tests/gpt-review-issues.d.ts +5 -0
- package/lib/tests/gpt-review-issues.js +255 -0
- package/lib/tests/json-to-lyl.d.ts +1 -0
- package/lib/tests/json-to-lyl.js +18 -0
- package/lib/tests/lilypond-roundtrip.d.ts +7 -0
- package/lib/tests/lilypond-roundtrip.js +558 -0
- package/lib/tests/lilypondDecoder.d.ts +6 -0
- package/lib/tests/lilypondDecoder.js +95 -0
- package/lib/tests/ly-to-lyl.d.ts +1 -0
- package/lib/tests/ly-to-lyl.js +12 -0
- package/lib/tests/mei.d.ts +1 -0
- package/lib/tests/mei.js +278 -0
- package/lib/tests/musicxml-decoder.d.ts +4 -0
- package/lib/tests/musicxml-decoder.js +61 -0
- package/lib/tests/musicxml-detail.d.ts +4 -0
- package/lib/tests/musicxml-detail.js +85 -0
- package/lib/tests/musicxml-fprod.d.ts +9 -0
- package/lib/tests/musicxml-fprod.js +153 -0
- package/lib/tests/musicxml-roundtrip.d.ts +7 -0
- package/lib/tests/musicxml-roundtrip.js +296 -0
- package/lib/tests/musicxml-to-mei.d.ts +6 -0
- package/lib/tests/musicxml-to-mei.js +115 -0
- package/lib/tests/parser.d.ts +1 -0
- package/lib/tests/parser.js +17 -0
- package/lib/tests/render-k283.d.ts +1 -0
- package/lib/tests/render-k283.js +33 -0
- package/lib/tests/render-lyl.d.ts +1 -0
- package/lib/tests/render-lyl.js +35 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
- package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
- package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
- package/lib/tests/unit/gptReviewIssues.test.js +240 -0
- package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
- package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
- package/lib/tests/unit/partialWarning.test.d.ts +4 -0
- package/lib/tests/unit/partialWarning.test.js +65 -0
- package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
- package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
- package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
- package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
- package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
- package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
- package/package.json +5 -2
- package/source/abc/abc.jison +90 -15
- package/source/abc/grammar.jison.js +300 -187
- package/source/lilylet/abcDecoder.ts +42 -14
- package/source/lilylet/lilypondEncoder.ts +2 -0
- package/source/lilylet/meiEncoder.ts +95 -48
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test: \tuplet N/M D { notes } with base-duration argument must
|
|
3
|
+
* preserve the tuplet wrapper in the decoded doc.
|
|
4
|
+
*
|
|
5
|
+
* Bug: The LilyPond decoder accessed args[1].body to find the tuplet body,
|
|
6
|
+
* but \tuplet 3/2 4 { ... } has args = ["3/2", "4", {body}].
|
|
7
|
+
* args[1] is "4" (the base duration), not the music block, so body was
|
|
8
|
+
* empty and all notes were silently decoded as plain notes outside any tuplet.
|
|
9
|
+
*
|
|
10
|
+
* Real-world case: chopin-28-14.ly — every measure is wrapped in
|
|
11
|
+
* \tuplet 3/2 4 { | notes }
|
|
12
|
+
* producing 12 eighth notes in the time of 8, but the decoded lyl had
|
|
13
|
+
* no \times wrapper at all.
|
|
14
|
+
*
|
|
15
|
+
* Usage: npx tsx tests/unit/tupletWithBaseDuration.test.ts
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test: \tuplet N/M D { notes } with base-duration argument must
|
|
3
|
+
* preserve the tuplet wrapper in the decoded doc.
|
|
4
|
+
*
|
|
5
|
+
* Bug: The LilyPond decoder accessed args[1].body to find the tuplet body,
|
|
6
|
+
* but \tuplet 3/2 4 { ... } has args = ["3/2", "4", {body}].
|
|
7
|
+
* args[1] is "4" (the base duration), not the music block, so body was
|
|
8
|
+
* empty and all notes were silently decoded as plain notes outside any tuplet.
|
|
9
|
+
*
|
|
10
|
+
* Real-world case: chopin-28-14.ly — every measure is wrapped in
|
|
11
|
+
* \tuplet 3/2 4 { | notes }
|
|
12
|
+
* producing 12 eighth notes in the time of 8, but the decoded lyl had
|
|
13
|
+
* no \times wrapper at all.
|
|
14
|
+
*
|
|
15
|
+
* Usage: npx tsx tests/unit/tupletWithBaseDuration.test.ts
|
|
16
|
+
*/
|
|
17
|
+
import { decode } from "../../source/lilylet/lilypondDecoder.js";
|
|
18
|
+
import { serializeLilyletDoc } from "../../source/lilylet/serializer.js";
|
|
19
|
+
let passed = 0;
|
|
20
|
+
let failed = 0;
|
|
21
|
+
function assert(condition, message) {
|
|
22
|
+
if (condition) {
|
|
23
|
+
console.log(` ✓ ${message}`);
|
|
24
|
+
passed++;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
console.error(` ✗ FAIL: ${message}`);
|
|
28
|
+
failed++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const LY_BOILERPLATE = `
|
|
32
|
+
\\version "2.22.0"
|
|
33
|
+
\\language "english"
|
|
34
|
+
\\header { tagline = ##f }
|
|
35
|
+
\\layout { \\context { \\Score autoBeaming = ##f } }
|
|
36
|
+
`;
|
|
37
|
+
// Warm-up
|
|
38
|
+
{
|
|
39
|
+
const w = console.warn, a = console.assert;
|
|
40
|
+
console.warn = () => { };
|
|
41
|
+
console.assert = () => { };
|
|
42
|
+
try {
|
|
43
|
+
await decode('{ c }');
|
|
44
|
+
}
|
|
45
|
+
catch { }
|
|
46
|
+
console.warn = w;
|
|
47
|
+
console.assert = a;
|
|
48
|
+
}
|
|
49
|
+
// ─── Test 1: \tuplet 3/2 4 { notes } preserves tuplet ─────────────────────
|
|
50
|
+
// Chopin 28-14 pattern: \tuplet 3/2 4 wraps 12 eighth notes per measure.
|
|
51
|
+
// Without base-duration: \tuplet 3/2 { ef8 bf gf ... } = 12 notes
|
|
52
|
+
// With base-duration: \tuplet 3/2 4 { | ef8 bf gf ... } = same semantics
|
|
53
|
+
console.log('\nTest 1: \\tuplet 3/2 4 { notes } — tuplet wrapper preserved');
|
|
54
|
+
await (async () => {
|
|
55
|
+
const LY = LY_BOILERPLATE + `
|
|
56
|
+
\\score {
|
|
57
|
+
\\new Staff {
|
|
58
|
+
\\new Voice {
|
|
59
|
+
\\relative ef, {
|
|
60
|
+
\\time 4/4
|
|
61
|
+
\\tuplet 3/2 4 {
|
|
62
|
+
ef8 [ bf' gf ] cf [ ef, cf' ] d, [ cf' f, ] bf [ d, bf']
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
\\layout {}
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
const doc = await decode(LY);
|
|
71
|
+
assert(doc.measures.length >= 1, `decoded 1 measure (got ${doc.measures.length})`);
|
|
72
|
+
if (!doc.measures[0])
|
|
73
|
+
return;
|
|
74
|
+
const voice = doc.measures[0].parts[0]?.voices[0];
|
|
75
|
+
assert(!!voice, 'voice exists');
|
|
76
|
+
if (!voice)
|
|
77
|
+
return;
|
|
78
|
+
// The voice should have a tuplet event, NOT 12 bare note events
|
|
79
|
+
const tuplets = voice.events.filter(e => e.type === 'tuplet' || e.type === 'times');
|
|
80
|
+
const bareNotes = voice.events.filter(e => e.type === 'note');
|
|
81
|
+
assert(tuplets.length >= 1, `voice contains at least 1 tuplet/times event (got ${tuplets.length}) — not bare notes`);
|
|
82
|
+
assert(bareNotes.length === 0, `no bare notes at top level (got ${bareNotes.length}) — all should be inside tuplet`);
|
|
83
|
+
if (tuplets.length >= 1) {
|
|
84
|
+
const t = tuplets[0];
|
|
85
|
+
const innerNotes = t.events.filter(e => e.type === 'note');
|
|
86
|
+
assert(innerNotes.length === 12, `tuplet contains 12 inner notes (got ${innerNotes.length})`);
|
|
87
|
+
assert(t.ratio.numerator === 2 && t.ratio.denominator === 3, `ratio is 2/3 (lilylet: play 12 eighth notes in time of 8) — got ${t.ratio.numerator}/${t.ratio.denominator}`);
|
|
88
|
+
}
|
|
89
|
+
})();
|
|
90
|
+
// ─── Test 2: \tuplet 3/2 (no base duration) — should still work ────────────
|
|
91
|
+
console.log('\nTest 2: \\tuplet 3/2 { notes } without base duration (baseline)');
|
|
92
|
+
await (async () => {
|
|
93
|
+
const LY = LY_BOILERPLATE + `
|
|
94
|
+
\\score {
|
|
95
|
+
\\new Staff {
|
|
96
|
+
\\new Voice {
|
|
97
|
+
\\relative ef, {
|
|
98
|
+
\\time 4/4
|
|
99
|
+
\\tuplet 3/2 {
|
|
100
|
+
ef8 [ bf' gf ] cf [ ef, cf' ] d, [ cf' f, ] bf [ d, bf']
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
\\layout {}
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
const doc = await decode(LY);
|
|
109
|
+
const voice = doc.measures[0]?.parts[0]?.voices[0];
|
|
110
|
+
const tuplets = voice?.events.filter(e => e.type === 'tuplet' || e.type === 'times') ?? [];
|
|
111
|
+
assert(tuplets.length >= 1, `\\tuplet 3/2 (no base dur) also produces tuplet event (got ${tuplets.length})`);
|
|
112
|
+
})();
|
|
113
|
+
// ─── Test 3: serialized lyl contains \tuplet or \times wrapper ─────────────
|
|
114
|
+
console.log('\nTest 3: serialized lyl contains tuplet notation');
|
|
115
|
+
await (async () => {
|
|
116
|
+
const LY = LY_BOILERPLATE + `
|
|
117
|
+
\\score {
|
|
118
|
+
\\new Staff {
|
|
119
|
+
\\new Voice {
|
|
120
|
+
\\relative ef, {
|
|
121
|
+
\\time 4/4
|
|
122
|
+
\\tuplet 3/2 4 {
|
|
123
|
+
ef8 [ bf' gf ] cf [ ef, cf' ] d, [ cf' f, ] bf [ d, bf']
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
\\layout {}
|
|
129
|
+
}
|
|
130
|
+
`;
|
|
131
|
+
const doc = await decode(LY);
|
|
132
|
+
const lyl = serializeLilyletDoc(doc);
|
|
133
|
+
console.log(` lyl: ${lyl.trim().split('\n').find(l => l.includes('time') || l.includes('tuplet') || l.includes('times')) ?? '(not found)'}`);
|
|
134
|
+
assert(lyl.includes('\\tuplet') || lyl.includes('\\times'), `serialized lyl contains \\tuplet or \\times notation (got no tuplet wrapper)`);
|
|
135
|
+
})();
|
|
136
|
+
// ─── summary ─────────────────────────────────────────────────────────────────
|
|
137
|
+
console.log(`\n${'═'.repeat(50)}`);
|
|
138
|
+
console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
|
|
139
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for voice.staff parsing edge cases from GPT review of the
|
|
3
|
+
* leadingStaff fix (commit c0763a4, 47fcf61).
|
|
4
|
+
*
|
|
5
|
+
* Cases:
|
|
6
|
+
* A. non-staff context before \staff "N" in same voice line
|
|
7
|
+
* B. second voice after \\ with no explicit \staff (fallback)
|
|
8
|
+
* C. trailing | + newline → spurious empty measure filtered
|
|
9
|
+
* D. measure with only \bar → filtered as empty
|
|
10
|
+
*
|
|
11
|
+
* Usage: npx tsx tests/unit/voiceStaffParsing.test.ts
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for voice.staff parsing edge cases from GPT review of the
|
|
3
|
+
* leadingStaff fix (commit c0763a4, 47fcf61).
|
|
4
|
+
*
|
|
5
|
+
* Cases:
|
|
6
|
+
* A. non-staff context before \staff "N" in same voice line
|
|
7
|
+
* B. second voice after \\ with no explicit \staff (fallback)
|
|
8
|
+
* C. trailing | + newline → spurious empty measure filtered
|
|
9
|
+
* D. measure with only \bar → filtered as empty
|
|
10
|
+
*
|
|
11
|
+
* Usage: npx tsx tests/unit/voiceStaffParsing.test.ts
|
|
12
|
+
*/
|
|
13
|
+
import { parseCode } from '../../source/lilylet/parser.js';
|
|
14
|
+
let passed = 0;
|
|
15
|
+
let failed = 0;
|
|
16
|
+
function assert(condition, message) {
|
|
17
|
+
if (condition) {
|
|
18
|
+
console.log(` ✓ ${message}`);
|
|
19
|
+
passed++;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
console.error(` ✗ FAIL: ${message}`);
|
|
23
|
+
failed++;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function voiceStaff(lyl, measureIdx = 0, voiceIdx = 0) {
|
|
27
|
+
const doc = parseCode(lyl);
|
|
28
|
+
return doc.measures[measureIdx]?.parts[0]?.voices[voiceIdx]?.staff ?? -1;
|
|
29
|
+
}
|
|
30
|
+
function measureCount(lyl) {
|
|
31
|
+
return parseCode(lyl).measures.length;
|
|
32
|
+
}
|
|
33
|
+
// ─── Case A1: \clef before \staff "2" ────────────────────────────────────────
|
|
34
|
+
console.log('\nCase A1: \\clef before \\staff "2" — staff must be 2');
|
|
35
|
+
{
|
|
36
|
+
const lyl = `\\clef "bass" \\staff "2" c4 d e f |`;
|
|
37
|
+
assert(voiceStaff(lyl) === 2, `\\clef "bass" \\staff "2" c4 → voice.staff=2 (got ${voiceStaff(lyl)})`);
|
|
38
|
+
}
|
|
39
|
+
// ─── Case A2: \key before \staff "2" ─────────────────────────────────────────
|
|
40
|
+
console.log('\nCase A2: \\key before \\staff "2" — staff must be 2');
|
|
41
|
+
{
|
|
42
|
+
const lyl = `\\key g \\major \\staff "2" c4 d e f |`;
|
|
43
|
+
assert(voiceStaff(lyl) === 2, `\\key g \\major \\staff "2" → voice.staff=2 (got ${voiceStaff(lyl)})`);
|
|
44
|
+
}
|
|
45
|
+
// ─── Case A3: \time \clef \key before \staff "2" \times ──────────────────────
|
|
46
|
+
console.log('\nCase A3: multiple context events before \\staff "2" \\times');
|
|
47
|
+
{
|
|
48
|
+
const lyl = `\\time 4/4 \\clef "treble" \\key c \\major \\staff "2" \\times 2/3 { c8 d e } r2. |`;
|
|
49
|
+
const doc = parseCode(lyl);
|
|
50
|
+
const voice = doc.measures[0]?.parts[0]?.voices[0];
|
|
51
|
+
assert(!!voice, 'voice exists');
|
|
52
|
+
if (voice) {
|
|
53
|
+
assert(voice.staff === 2, `context chain before \\staff "2" → voice.staff=2 (got ${voice.staff})`);
|
|
54
|
+
// Verify the event stream: leading context events (non-staff) then staff=2 context before tuplet
|
|
55
|
+
const staffCtxIdx = voice.events.findIndex((e) => e.type === 'context' && e.staff === 2);
|
|
56
|
+
const tupletIdx = voice.events.findIndex((e) => e.type === 'tuplet' || e.type === 'times');
|
|
57
|
+
assert(staffCtxIdx >= 0, `context{staff:2} event exists in events`);
|
|
58
|
+
assert(tupletIdx >= 0, `tuplet event exists in events`);
|
|
59
|
+
assert(staffCtxIdx < tupletIdx, `context{staff:2} (idx=${staffCtxIdx}) appears before tuplet (idx=${tupletIdx})`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ─── Case A4: \staff "1" first, cross-staff switch — staff must be 1 ─────────
|
|
63
|
+
console.log('\nCase A4: \\staff "1" then \\staff "2" — leading staff is 1');
|
|
64
|
+
{
|
|
65
|
+
const lyl = `\\staff "1" c4 d \\staff "2" e f |`;
|
|
66
|
+
assert(voiceStaff(lyl) === 1, `\\staff "1" c4 d \\staff "2" e f → voice.staff=1 (got ${voiceStaff(lyl)})`);
|
|
67
|
+
}
|
|
68
|
+
// ─── Case B1: second voice — stale currentStaff=2 must NOT leak ──────────────
|
|
69
|
+
// Voice 0 ends after switching to \staff "2" (currentStaff=2 is stale).
|
|
70
|
+
// Voice 1 has no explicit \staff — should default to staff=1 (part_start reset),
|
|
71
|
+
// NOT inherit the stale currentStaff=2 from voice 0.
|
|
72
|
+
console.log('\nCase B1: second voice (no \\staff) must NOT inherit stale currentStaff=2');
|
|
73
|
+
{
|
|
74
|
+
const lyl = `\\staff "1" c4 \\staff "2" d e f \\\\ g4 a b c |`;
|
|
75
|
+
const doc = parseCode(lyl);
|
|
76
|
+
const voices = doc.measures[0]?.parts[0]?.voices ?? [];
|
|
77
|
+
assert(voices.length === 2, `two voices parsed (got ${voices.length})`);
|
|
78
|
+
assert(voices[0].staff === 1, `voice 0 leading \\staff "1" (got ${voices[0]?.staff})`);
|
|
79
|
+
// part_start resets currentStaff=1 for each part_voices attempt;
|
|
80
|
+
// if stale currentStaff=2 leaked, voice 1 would wrongly get staff=2
|
|
81
|
+
assert(voices[1].staff === 1, `voice 1 no \\staff → defaults to 1, not stale 2 (got ${voices[1]?.staff})`);
|
|
82
|
+
}
|
|
83
|
+
// ─── Case B2: \\ then \staff "2" ─────────────────────────────────────────────
|
|
84
|
+
console.log('\nCase B2: second voice starts with \\staff "2"');
|
|
85
|
+
{
|
|
86
|
+
const lyl = `\\staff "1" c4 d e f \\\\ \\staff "2" c4 d e f |`;
|
|
87
|
+
const doc = parseCode(lyl);
|
|
88
|
+
const voices = doc.measures[0]?.parts[0]?.voices ?? [];
|
|
89
|
+
assert(voices.length === 2, `two voices parsed (got ${voices.length})`);
|
|
90
|
+
assert(voices[1].staff === 2, `voice 1 \\staff "2" → staff=2 (got ${voices[1]?.staff})`);
|
|
91
|
+
}
|
|
92
|
+
// ─── Case C: trailing newline after last | — no spurious measure ─────────────
|
|
93
|
+
console.log('\nCase C: trailing newline after last | — no spurious empty measure');
|
|
94
|
+
{
|
|
95
|
+
// lyl with explicit trailing newline
|
|
96
|
+
const lyl = `\\staff "1" c4 d e f |\n`;
|
|
97
|
+
const count = measureCount(lyl);
|
|
98
|
+
assert(count === 1, `exactly 1 measure (got ${count})`);
|
|
99
|
+
}
|
|
100
|
+
// ─── Case D: measure with only \bar — filtered as empty ──────────────────────
|
|
101
|
+
console.log('\nCase D: measure with only \\bar "|." — filtered as empty');
|
|
102
|
+
{
|
|
103
|
+
const lyl = `\\staff "1" c4 d e f | \\bar "|." |`;
|
|
104
|
+
const count = measureCount(lyl);
|
|
105
|
+
// The real measure (c4 d e f) is kept; the barline-only measure is filtered
|
|
106
|
+
assert(count === 1, `barline-only measure filtered, 1 real measure remains (got ${count})`);
|
|
107
|
+
}
|
|
108
|
+
// ─── Case E: measure with notes then trailing empty measure ──────────────────
|
|
109
|
+
console.log('\nCase E: two real measures, no spurious third');
|
|
110
|
+
{
|
|
111
|
+
const lyl = `\\staff "1" c4 d e f | g4 a b c |\n`;
|
|
112
|
+
const count = measureCount(lyl);
|
|
113
|
+
assert(count === 2, `exactly 2 measures (got ${count})`);
|
|
114
|
+
}
|
|
115
|
+
// ─── Summary ─────────────────────────────────────────────────────────────────
|
|
116
|
+
console.log(`\n${'═'.repeat(50)}`);
|
|
117
|
+
console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
|
|
118
|
+
process.exit(failed > 0 ? 1 : 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@k-l-lambda/lilylet",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.64",
|
|
4
4
|
"description": "Lilylet is a lilyopnd-like sheet music language designed for Markdown rendering and symbolic music representation in AIGC applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -29,11 +29,13 @@
|
|
|
29
29
|
"prepublishOnly": "npm run build:grammar && npm run build",
|
|
30
30
|
"test": "tsx ./tests/parser.ts",
|
|
31
31
|
"test:mei": "tsx ./tests/mei.ts",
|
|
32
|
+
"test:mei-hashes": "tsx ./tests/computeMeiHashes.ts",
|
|
32
33
|
"test:unit": "tsx ./tests/unit/encodePitch.test.ts",
|
|
33
34
|
"test:partial": "tsx ./tests/unit/partialWarning.test.ts",
|
|
34
35
|
"test:decoder": "tsx ./tests/lilypondDecoder.ts",
|
|
35
36
|
"test:abc": "tsx ./tests/abc-decoder.ts",
|
|
36
37
|
"test:roundtrip": "tsx ./tests/lilypond-roundtrip.ts",
|
|
38
|
+
"test:abc-svg": "tsx ./tests/abc-abcjs-svg.ts",
|
|
37
39
|
"build:tests": "tsc -p tsconfig.tests.json; cp source/lilylet/grammar.jison.js lib-tests/source/lilylet/ && cp source/abc/grammar.jison.js lib-tests/source/abc/ && node tools/fixEsmExtensions.cjs lib-tests && ln -sfn ../../tests/assets lib-tests/tests/assets",
|
|
38
40
|
"test:roundtrip:compiled": "node lib-tests/tests/lilypond-roundtrip.js",
|
|
39
41
|
"ts": "tsx"
|
|
@@ -51,14 +53,15 @@
|
|
|
51
53
|
"devDependencies": {
|
|
52
54
|
"@types/node": "^20.11.20",
|
|
53
55
|
"@types/yargs": "^17.0.32",
|
|
56
|
+
"abcjs": "^6.6.2",
|
|
54
57
|
"formidable": "^3.5.4",
|
|
55
58
|
"jison": "^0.4.18",
|
|
59
|
+
"jsdom": "^29.0.2",
|
|
56
60
|
"sha1": "^1.1.1",
|
|
57
61
|
"ts-node": "^10.9.2",
|
|
58
62
|
"tsx": "^4.21.0",
|
|
59
63
|
"typescript": "^5.3.3",
|
|
60
64
|
"verovio": "^5.7.0",
|
|
61
|
-
"xmldom": "^0.6.0",
|
|
62
65
|
"yargs": "^17.7.2"
|
|
63
66
|
},
|
|
64
67
|
"dependencies": {
|
package/source/abc/abc.jison
CHANGED
|
@@ -82,18 +82,24 @@
|
|
|
82
82
|
const {patches} = body;
|
|
83
83
|
const measures = [];
|
|
84
84
|
let measure = null;
|
|
85
|
-
|
|
85
|
+
const seenVoices = new Set();
|
|
86
|
+
|
|
86
87
|
patches.forEach(patch => {
|
|
87
88
|
const voice = patch.control.V || 1;
|
|
88
|
-
if (voice
|
|
89
|
+
if (seenVoices.has(voice)) {
|
|
89
90
|
if (measure)
|
|
90
91
|
measures.push(measure);
|
|
91
92
|
measure = {voices: []};
|
|
93
|
+
seenVoices.clear();
|
|
92
94
|
}
|
|
95
|
+
if (!measure)
|
|
96
|
+
measure = {voices: []};
|
|
97
|
+
seenVoices.add(voice);
|
|
93
98
|
measure.voices.push(patch);
|
|
94
99
|
});
|
|
95
100
|
|
|
96
|
-
|
|
101
|
+
if (measure)
|
|
102
|
+
measures.push(measure);
|
|
97
103
|
|
|
98
104
|
measures.forEach((measure, index) => measure.index = index + 1);
|
|
99
105
|
|
|
@@ -151,6 +157,21 @@
|
|
|
151
157
|
|
|
152
158
|
|
|
153
159
|
const octaveShift = shift => ({octaveShift: shift});
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
const parseMode = (name) => {
|
|
163
|
+
const n = name.toLowerCase();
|
|
164
|
+
if (n.startsWith("ma")) return "major";
|
|
165
|
+
if (n === "m" || n.startsWith("mi")) return "minor";
|
|
166
|
+
if (n.startsWith("dor")) return "dorian";
|
|
167
|
+
if (n.startsWith("phr")) return "phrygian";
|
|
168
|
+
if (n.startsWith("lyd")) return "lydian";
|
|
169
|
+
if (n.startsWith("mix")) return "mixolydian";
|
|
170
|
+
if (n.startsWith("aeo")) return "aeolian";
|
|
171
|
+
if (n.startsWith("loc")) return "locrian";
|
|
172
|
+
if (n === "hp") return "highland";
|
|
173
|
+
return n;
|
|
174
|
+
};
|
|
154
175
|
%}
|
|
155
176
|
|
|
156
177
|
|
|
@@ -160,24 +181,27 @@
|
|
|
160
181
|
|
|
161
182
|
%x string
|
|
162
183
|
%x comment
|
|
184
|
+
%x spec_comment_name
|
|
163
185
|
%x spec_comment
|
|
186
|
+
%x spec_comment_skip
|
|
164
187
|
%x title_string
|
|
165
188
|
%x key_signature
|
|
189
|
+
%x voice_header
|
|
166
190
|
%x exclamation_exp
|
|
167
191
|
|
|
168
|
-
|
|
169
192
|
H \b[A-Z](?=\:[^|])
|
|
170
|
-
A \b[A-G](?=[\W\d\sA-Ga-
|
|
193
|
+
A \b[A-G](?=[\W\d\sA-Ga-g_yzHJLMOPRSTuv]*\b)
|
|
171
194
|
Am \b[A-G](?=[m][a][j]|[m][i][n]\b)
|
|
172
|
-
a \b[a-g](?=[\W\d\sA-Ga-
|
|
195
|
+
a \b[a-g](?=[\W\d\sA-Ga-g_yzHJLMOPRSTuv]*\b)
|
|
173
196
|
z \b[z]
|
|
174
197
|
Z \b[Z]
|
|
175
198
|
x \b[x](?=[\W\d\s])
|
|
199
|
+
y \b[y]
|
|
176
200
|
N [0-9]
|
|
177
201
|
P \b[HJLMOPRSTuv](?=[A-Ga-g][A-Ga-g0-9]*\b)
|
|
178
202
|
PP \b[HJLMOPRSTuv](?=[xz!\[^_=\s"])
|
|
179
203
|
|
|
180
|
-
SPECIAL [:!^_,'/<>={}()\[\]
|
|
204
|
+
SPECIAL [:!^_,'/<>={}()\[\]|.\-+~&]
|
|
181
205
|
|
|
182
206
|
|
|
183
207
|
%%
|
|
@@ -193,10 +217,30 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~]
|
|
|
193
217
|
<title_string>[^\n]+ return 'STR_CONTENT'
|
|
194
218
|
|
|
195
219
|
^[K][:][\s]* { this.pushState('key_signature'); return 'K:'; }
|
|
220
|
+
^[V][:][ \t]* { this.pushState('voice_header'); return 'V:'; }
|
|
221
|
+
<voice_header>\" { this.pushState('string'); return 'STR_START'; }
|
|
222
|
+
<voice_header>[a-zA-Z][a-zA-Z0-9,]* return 'NAME';
|
|
223
|
+
<voice_header>[0-9]+ return 'N';
|
|
224
|
+
<voice_header>[=] return '=';
|
|
225
|
+
<voice_header>[+\-] return yytext;
|
|
226
|
+
<voice_header>[ \t]+ {}
|
|
227
|
+
<voice_header>\n { this.popState(); }
|
|
228
|
+
<voice_header>\] { this.popState(); return ']'; }
|
|
196
229
|
<key_signature>"treble" return 'TREBLE';
|
|
197
230
|
<key_signature>"bass" return 'BASS';
|
|
198
231
|
<key_signature>"tenor" return 'TENOR';
|
|
232
|
+
<key_signature>"none" return 'NAME';
|
|
233
|
+
<key_signature>"Dor" return 'NAME';
|
|
234
|
+
<key_signature>"Phr" return 'NAME';
|
|
235
|
+
<key_signature>"Lyd" return 'NAME';
|
|
236
|
+
<key_signature>"Mix" return 'NAME';
|
|
237
|
+
<key_signature>"Aeo" return 'NAME';
|
|
238
|
+
<key_signature>"Loc" return 'NAME';
|
|
239
|
+
<key_signature>"HP" return 'NAME';
|
|
240
|
+
<key_signature>"Hp" return 'NAME';
|
|
241
|
+
<key_signature>[a-z]+[ \t]*=[^\n\]]* {}
|
|
199
242
|
<key_signature>[A-G] return 'A';
|
|
243
|
+
<key_signature>[A-Z][a-z]+ return 'NAME';
|
|
200
244
|
<key_signature>[b] return 'FLAT';
|
|
201
245
|
<key_signature>[#] return 'SHARP';
|
|
202
246
|
<key_signature>[m][a-z]* return 'NAME';
|
|
@@ -208,14 +252,20 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~]
|
|
|
208
252
|
<key_signature>\] { this.popState(); return ']'; }
|
|
209
253
|
|
|
210
254
|
^[%] { this.pushState('comment'); }
|
|
211
|
-
<comment>[%] { this.pushState('
|
|
255
|
+
<comment>[%] { this.pushState('spec_comment_name'); }
|
|
212
256
|
<comment>[^\n]+ { return 'COMMENT'; }
|
|
213
|
-
<spec_comment>\n { this.popState(); this.popState(); }
|
|
214
257
|
<comment>\n { this.popState(); }
|
|
215
|
-
<
|
|
216
|
-
<
|
|
217
|
-
<spec_comment
|
|
258
|
+
<spec_comment_name>[ \t]+ {}
|
|
259
|
+
<spec_comment_name>"score" { this.popState(); this.pushState('spec_comment'); return 'SCORE'; }
|
|
260
|
+
<spec_comment_name>"staves" { this.popState(); this.pushState('spec_comment'); return 'SCORE'; }
|
|
261
|
+
<spec_comment_name>[\w]+ { this.popState(); this.pushState('spec_comment_skip'); }
|
|
262
|
+
<spec_comment_name>\n { this.popState(); this.popState(); }
|
|
263
|
+
<spec_comment>[ \t]+ {}
|
|
218
264
|
<spec_comment>[(){}\[\]|] return yytext
|
|
265
|
+
<spec_comment>[\w]+ return 'NN'
|
|
266
|
+
<spec_comment>\n { this.popState(); this.popState(); return 'LAYOUT_END'; }
|
|
267
|
+
<spec_comment_skip>[^\n]+ {}
|
|
268
|
+
<spec_comment_skip>\n { this.popState(); this.popState(); }
|
|
219
269
|
|
|
220
270
|
[!] { this.pushState('exclamation_exp'); return '!'; }
|
|
221
271
|
<exclamation_exp>[!] { this.popState(); return '!'; }
|
|
@@ -231,6 +281,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~]
|
|
|
231
281
|
<exclamation_exp>{N} return 'N'
|
|
232
282
|
<exclamation_exp>[a-zA-Z][\w-]* return 'NAME'
|
|
233
283
|
|
|
284
|
+
\\\n {}
|
|
234
285
|
\s+ {}
|
|
235
286
|
|
|
236
287
|
{SPECIAL} return yytext
|
|
@@ -250,6 +301,7 @@ SPECIAL [:!^_,'/<>={}()\[\]|.\-+~]
|
|
|
250
301
|
"staff" return 'STAFF'
|
|
251
302
|
"maj" return 'MAJ'
|
|
252
303
|
"min" return 'MIN'
|
|
304
|
+
{y} return 'y'
|
|
253
305
|
[a-zA-Z][\w-]* return 'NAME'
|
|
254
306
|
|
|
255
307
|
<<EOF>> return 'EOF'
|
|
@@ -284,6 +336,7 @@ head_lines
|
|
|
284
336
|
| head_lines head_line -> [...$1, $2]
|
|
285
337
|
| head_lines comment -> [...$1, $2]
|
|
286
338
|
| head_lines staff_layout_statement -> [...$1, $2]
|
|
339
|
+
| head_lines 'LAYOUT_END' -> $1
|
|
287
340
|
| head_lines ']' -> $1
|
|
288
341
|
| head_lines '}' -> $1
|
|
289
342
|
| head_lines ')' -> $1
|
|
@@ -294,7 +347,7 @@ comment
|
|
|
294
347
|
;
|
|
295
348
|
|
|
296
349
|
staff_layout_statement
|
|
297
|
-
: 'SCORE' staff_layout
|
|
350
|
+
: 'SCORE' staff_layout 'LAYOUT_END' -> $2
|
|
298
351
|
;
|
|
299
352
|
|
|
300
353
|
staff_layout
|
|
@@ -320,6 +373,7 @@ head_line
|
|
|
320
373
|
: 'T:' string_content -> header('T', $2)
|
|
321
374
|
| 'C:' string_content -> header('C', $2)
|
|
322
375
|
| 'K:' key_signature -> header('K', $2)
|
|
376
|
+
| 'V:' header_value -> header('V', $2)
|
|
323
377
|
| H ':' header_value -> header($1, $3)
|
|
324
378
|
;
|
|
325
379
|
|
|
@@ -328,6 +382,7 @@ header_value
|
|
|
328
382
|
| number
|
|
329
383
|
| frac
|
|
330
384
|
| numeric_tempo
|
|
385
|
+
| string frac '=' number -> ({text: $1, note: $2, bpm: $4})
|
|
331
386
|
| upper_phonet
|
|
332
387
|
| voice_exp
|
|
333
388
|
| staff_shift
|
|
@@ -342,6 +397,11 @@ staff_shift
|
|
|
342
397
|
;
|
|
343
398
|
|
|
344
399
|
key_signature
|
|
400
|
+
: key_root -> $1
|
|
401
|
+
| NAME -> key(null, $1)
|
|
402
|
+
;
|
|
403
|
+
|
|
404
|
+
key_root
|
|
345
405
|
: A -> key($1, null)
|
|
346
406
|
| A sharp_or_flat -> key($1 + $2, null)
|
|
347
407
|
| A key_mode -> key($1, $2)
|
|
@@ -362,7 +422,7 @@ sharp_or_flat
|
|
|
362
422
|
key_mode
|
|
363
423
|
: MAJ -> 'major'
|
|
364
424
|
| MIN -> 'minor'
|
|
365
|
-
| NAME -> $1
|
|
425
|
+
| NAME -> parseMode($1)
|
|
366
426
|
;
|
|
367
427
|
|
|
368
428
|
plus_minus_number
|
|
@@ -402,6 +462,10 @@ voice_exp
|
|
|
402
462
|
| number NAME assigns -> voice($1, $2, $3)
|
|
403
463
|
| NAME -> voice(1, $1)
|
|
404
464
|
| NAME assigns -> voice(1, $1, $2)
|
|
465
|
+
| upper_phonet number -> voice(1, $1 + String($2))
|
|
466
|
+
| upper_phonet number assigns -> voice(1, $1 + String($2), $3)
|
|
467
|
+
| upper_phonet number NAME -> voice(1, $1 + String($2))
|
|
468
|
+
| upper_phonet number NAME assigns -> voice(1, $1 + String($2), $4)
|
|
405
469
|
;
|
|
406
470
|
|
|
407
471
|
assigns
|
|
@@ -418,6 +482,7 @@ assign_value
|
|
|
418
482
|
| number
|
|
419
483
|
| plus_minus_number
|
|
420
484
|
| NAME
|
|
485
|
+
| upper_phonet
|
|
421
486
|
;
|
|
422
487
|
|
|
423
488
|
upper_phonet
|
|
@@ -437,6 +502,9 @@ patches
|
|
|
437
502
|
| patches tailless_patch -> [...$1, $2]
|
|
438
503
|
| patches ']' -> $1
|
|
439
504
|
| patches '}' -> $1
|
|
505
|
+
| patches head_line -> $1
|
|
506
|
+
| patches 'LAYOUT_END' -> $1
|
|
507
|
+
| patches '&' patch -> $1
|
|
440
508
|
;
|
|
441
509
|
|
|
442
510
|
patch
|
|
@@ -459,10 +527,11 @@ bar
|
|
|
459
527
|
| ':' '|' ']' -> ':|]'
|
|
460
528
|
| '|' N -> '|' + $2
|
|
461
529
|
| ':' '|' N -> ':|' + $2
|
|
530
|
+
| '&' -> '&'
|
|
462
531
|
;
|
|
463
532
|
|
|
464
533
|
music
|
|
465
|
-
: %empty
|
|
534
|
+
: %empty -> []
|
|
466
535
|
| music expressive_mark -> $1 ? [...$1, $2] : [$2]
|
|
467
536
|
| music text -> $1 ? [...$1, $2] : [$2]
|
|
468
537
|
| music event -> $1 ? [...$1, $2] : [$2]
|
|
@@ -474,11 +543,13 @@ music
|
|
|
474
543
|
| music NAME -> $1
|
|
475
544
|
| music '^' NAME -> $1
|
|
476
545
|
| music '^' -> $1
|
|
546
|
+
| music '[' N -> $1
|
|
477
547
|
;
|
|
478
548
|
|
|
479
549
|
control
|
|
480
550
|
: '[' H ':' header_value ']' -> ({control: header($2, $4)})
|
|
481
551
|
| '[' 'K:' header_value ']' -> ({control: header("K", $3)})
|
|
552
|
+
| '[' 'V:' header_value ']' -> ({control: header("V", $3)})
|
|
482
553
|
| '[' NAME ':' header_value ']' -> ({control: header($2, $4)})
|
|
483
554
|
;
|
|
484
555
|
|
|
@@ -510,6 +581,7 @@ articulation
|
|
|
510
581
|
articulation_content
|
|
511
582
|
: scope_articulation -> articulation($1)
|
|
512
583
|
| scope_articulation parenthese -> articulation($1, $2)
|
|
584
|
+
| scope_articulation '=' assign_value -> articulation($1 + '=' + String($3))
|
|
513
585
|
| DYNAMIC -> articulation($1)
|
|
514
586
|
| a -> articulation($1)
|
|
515
587
|
| "^" -> articulation($1)
|
|
@@ -613,6 +685,8 @@ accidentals
|
|
|
613
685
|
| '^' '^' -> 2
|
|
614
686
|
| '_' '_' -> -2
|
|
615
687
|
| '=' '=' -> 0
|
|
688
|
+
| '^' '/' -> 0.5
|
|
689
|
+
| '_' '/' -> -0.5
|
|
616
690
|
;
|
|
617
691
|
|
|
618
692
|
pitch
|
|
@@ -637,6 +711,7 @@ rest_phonet
|
|
|
637
711
|
: z
|
|
638
712
|
| Z
|
|
639
713
|
| x
|
|
714
|
+
| y
|
|
640
715
|
;
|
|
641
716
|
|
|
642
717
|
event
|