@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.
Files changed (108) hide show
  1. package/lib/abc/grammar.jison.js +300 -187
  2. package/lib/lilylet/abcDecoder.js +40 -12
  3. package/lib/lilylet/lilypondEncoder.js +3 -0
  4. package/lib/lilylet/meiEncoder.js +87 -48
  5. package/lib/source/abc/abc.d.ts +102 -0
  6. package/lib/source/abc/abc.js +25 -0
  7. package/lib/source/abc/parser.d.ts +3 -0
  8. package/lib/source/abc/parser.js +6 -0
  9. package/lib/source/lilylet/abcDecoder.d.ts +25 -0
  10. package/lib/source/lilylet/abcDecoder.js +1035 -0
  11. package/lib/source/lilylet/index.d.ts +10 -0
  12. package/lib/source/lilylet/index.js +10 -0
  13. package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
  14. package/lib/source/lilylet/lilypondDecoder.js +1223 -0
  15. package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
  16. package/lib/source/lilylet/lilypondEncoder.js +893 -0
  17. package/lib/source/lilylet/meiEncoder.d.ts +8 -0
  18. package/lib/source/lilylet/meiEncoder.js +1985 -0
  19. package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
  20. package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
  21. package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
  22. package/lib/source/lilylet/musicXmlEncoder.js +701 -0
  23. package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
  24. package/lib/source/lilylet/musicXmlTypes.js +7 -0
  25. package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
  26. package/lib/source/lilylet/musicXmlUtils.js +469 -0
  27. package/lib/source/lilylet/parser.d.ts +14 -0
  28. package/lib/source/lilylet/parser.js +161 -0
  29. package/lib/source/lilylet/serializer.d.ts +11 -0
  30. package/lib/source/lilylet/serializer.js +791 -0
  31. package/lib/source/lilylet/types.d.ts +253 -0
  32. package/lib/source/lilylet/types.js +100 -0
  33. package/lib/tests/abc-abcjs-parse.d.ts +8 -0
  34. package/lib/tests/abc-abcjs-parse.js +90 -0
  35. package/lib/tests/abc-abcjs-svg.d.ts +1 -0
  36. package/lib/tests/abc-abcjs-svg.js +143 -0
  37. package/lib/tests/abc-decoder.d.ts +1 -0
  38. package/lib/tests/abc-decoder.js +67 -0
  39. package/lib/tests/abc-mei-compare.d.ts +1 -0
  40. package/lib/tests/abc-mei-compare.js +525 -0
  41. package/lib/tests/auto-beam.d.ts +9 -0
  42. package/lib/tests/auto-beam.js +151 -0
  43. package/lib/tests/computeMeiHashes.d.ts +1 -0
  44. package/lib/tests/computeMeiHashes.js +87 -0
  45. package/lib/tests/encoder-mutation.d.ts +9 -0
  46. package/lib/tests/encoder-mutation.js +110 -0
  47. package/lib/tests/gpt-review-issues.d.ts +5 -0
  48. package/lib/tests/gpt-review-issues.js +255 -0
  49. package/lib/tests/json-to-lyl.d.ts +1 -0
  50. package/lib/tests/json-to-lyl.js +18 -0
  51. package/lib/tests/lilypond-roundtrip.d.ts +7 -0
  52. package/lib/tests/lilypond-roundtrip.js +558 -0
  53. package/lib/tests/lilypondDecoder.d.ts +6 -0
  54. package/lib/tests/lilypondDecoder.js +95 -0
  55. package/lib/tests/ly-to-lyl.d.ts +1 -0
  56. package/lib/tests/ly-to-lyl.js +12 -0
  57. package/lib/tests/mei.d.ts +1 -0
  58. package/lib/tests/mei.js +278 -0
  59. package/lib/tests/musicxml-decoder.d.ts +4 -0
  60. package/lib/tests/musicxml-decoder.js +61 -0
  61. package/lib/tests/musicxml-detail.d.ts +4 -0
  62. package/lib/tests/musicxml-detail.js +85 -0
  63. package/lib/tests/musicxml-fprod.d.ts +9 -0
  64. package/lib/tests/musicxml-fprod.js +153 -0
  65. package/lib/tests/musicxml-roundtrip.d.ts +7 -0
  66. package/lib/tests/musicxml-roundtrip.js +296 -0
  67. package/lib/tests/musicxml-to-mei.d.ts +6 -0
  68. package/lib/tests/musicxml-to-mei.js +115 -0
  69. package/lib/tests/parser.d.ts +1 -0
  70. package/lib/tests/parser.js +17 -0
  71. package/lib/tests/render-k283.d.ts +1 -0
  72. package/lib/tests/render-k283.js +33 -0
  73. package/lib/tests/render-lyl.d.ts +1 -0
  74. package/lib/tests/render-lyl.js +35 -0
  75. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
  76. package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
  77. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
  78. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
  79. package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
  80. package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
  81. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
  82. package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
  83. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
  84. package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
  85. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
  86. package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
  87. package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
  88. package/lib/tests/unit/gptReviewIssues.test.js +240 -0
  89. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
  90. package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
  91. package/lib/tests/unit/partialWarning.test.d.ts +4 -0
  92. package/lib/tests/unit/partialWarning.test.js +65 -0
  93. package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
  94. package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
  95. package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
  96. package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
  97. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
  98. package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
  99. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
  100. package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
  101. package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
  102. package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
  103. package/package.json +5 -2
  104. package/source/abc/abc.jison +90 -15
  105. package/source/abc/grammar.jison.js +300 -187
  106. package/source/lilylet/abcDecoder.ts +42 -14
  107. package/source/lilylet/lilypondEncoder.ts +2 -0
  108. 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.62",
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": {
@@ -82,18 +82,24 @@
82
82
  const {patches} = body;
83
83
  const measures = [];
84
84
  let measure = null;
85
- let lastVoice = 1;
85
+ const seenVoices = new Set();
86
+
86
87
  patches.forEach(patch => {
87
88
  const voice = patch.control.V || 1;
88
- if (voice <= lastVoice) {
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
- measures.push(measure);
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-g_zHJLMOPRSTuv]*\b)
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-g_zHJLMOPRSTuv]*\b)
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('spec_comment'); }
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
- <spec_comment>\s {}
216
- <spec_comment>"score" return 'SCORE'
217
- <spec_comment>[\w]+ return 'NN'
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 -> $2
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.startsWith("ma") ? "major" : "minor"
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