@k-l-lambda/lilylet 0.1.71 → 0.1.73

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 (104) hide show
  1. package/lib/highlight.d.ts +1 -0
  2. package/lib/highlight.js +1 -0
  3. package/lib/lilylet/highlight.d.ts +29 -0
  4. package/lib/lilylet/highlight.js +145 -0
  5. package/package.json +8 -2
  6. package/source/lilylet/highlight.ts +192 -0
  7. package/lib/source/abc/abc.d.ts +0 -102
  8. package/lib/source/abc/abc.js +0 -25
  9. package/lib/source/abc/parser.d.ts +0 -3
  10. package/lib/source/abc/parser.js +0 -6
  11. package/lib/source/lilylet/abcDecoder.d.ts +0 -25
  12. package/lib/source/lilylet/abcDecoder.js +0 -1035
  13. package/lib/source/lilylet/index.d.ts +0 -10
  14. package/lib/source/lilylet/index.js +0 -10
  15. package/lib/source/lilylet/lilypondDecoder.d.ts +0 -29
  16. package/lib/source/lilylet/lilypondDecoder.js +0 -1223
  17. package/lib/source/lilylet/lilypondEncoder.d.ts +0 -34
  18. package/lib/source/lilylet/lilypondEncoder.js +0 -893
  19. package/lib/source/lilylet/meiEncoder.d.ts +0 -8
  20. package/lib/source/lilylet/meiEncoder.js +0 -1985
  21. package/lib/source/lilylet/musicXmlDecoder.d.ts +0 -20
  22. package/lib/source/lilylet/musicXmlDecoder.js +0 -1195
  23. package/lib/source/lilylet/musicXmlEncoder.d.ts +0 -15
  24. package/lib/source/lilylet/musicXmlEncoder.js +0 -701
  25. package/lib/source/lilylet/musicXmlTypes.d.ts +0 -199
  26. package/lib/source/lilylet/musicXmlTypes.js +0 -7
  27. package/lib/source/lilylet/musicXmlUtils.d.ts +0 -92
  28. package/lib/source/lilylet/musicXmlUtils.js +0 -469
  29. package/lib/source/lilylet/parser.d.ts +0 -14
  30. package/lib/source/lilylet/parser.js +0 -161
  31. package/lib/source/lilylet/serializer.d.ts +0 -11
  32. package/lib/source/lilylet/serializer.js +0 -791
  33. package/lib/source/lilylet/types.d.ts +0 -253
  34. package/lib/source/lilylet/types.js +0 -100
  35. package/lib/tests/abc-abcjs-parse.d.ts +0 -8
  36. package/lib/tests/abc-abcjs-parse.js +0 -90
  37. package/lib/tests/abc-abcjs-svg.d.ts +0 -1
  38. package/lib/tests/abc-abcjs-svg.js +0 -143
  39. package/lib/tests/abc-decoder.d.ts +0 -1
  40. package/lib/tests/abc-decoder.js +0 -67
  41. package/lib/tests/abc-mei-compare.d.ts +0 -1
  42. package/lib/tests/abc-mei-compare.js +0 -525
  43. package/lib/tests/auto-beam.d.ts +0 -9
  44. package/lib/tests/auto-beam.js +0 -151
  45. package/lib/tests/computeMeiHashes.d.ts +0 -1
  46. package/lib/tests/computeMeiHashes.js +0 -87
  47. package/lib/tests/encoder-mutation.d.ts +0 -9
  48. package/lib/tests/encoder-mutation.js +0 -110
  49. package/lib/tests/gpt-review-issues.d.ts +0 -5
  50. package/lib/tests/gpt-review-issues.js +0 -255
  51. package/lib/tests/json-to-lyl.d.ts +0 -1
  52. package/lib/tests/json-to-lyl.js +0 -18
  53. package/lib/tests/lilypond-roundtrip.d.ts +0 -7
  54. package/lib/tests/lilypond-roundtrip.js +0 -558
  55. package/lib/tests/lilypondDecoder.d.ts +0 -6
  56. package/lib/tests/lilypondDecoder.js +0 -95
  57. package/lib/tests/ly-to-lyl.d.ts +0 -1
  58. package/lib/tests/ly-to-lyl.js +0 -12
  59. package/lib/tests/mei.d.ts +0 -1
  60. package/lib/tests/mei.js +0 -278
  61. package/lib/tests/musicxml-decoder.d.ts +0 -4
  62. package/lib/tests/musicxml-decoder.js +0 -61
  63. package/lib/tests/musicxml-detail.d.ts +0 -4
  64. package/lib/tests/musicxml-detail.js +0 -85
  65. package/lib/tests/musicxml-fprod.d.ts +0 -9
  66. package/lib/tests/musicxml-fprod.js +0 -153
  67. package/lib/tests/musicxml-roundtrip.d.ts +0 -7
  68. package/lib/tests/musicxml-roundtrip.js +0 -296
  69. package/lib/tests/musicxml-to-mei.d.ts +0 -6
  70. package/lib/tests/musicxml-to-mei.js +0 -115
  71. package/lib/tests/parser.d.ts +0 -1
  72. package/lib/tests/parser.js +0 -17
  73. package/lib/tests/render-k283.d.ts +0 -1
  74. package/lib/tests/render-k283.js +0 -33
  75. package/lib/tests/render-lyl.d.ts +0 -1
  76. package/lib/tests/render-lyl.js +0 -35
  77. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +0 -23
  78. package/lib/tests/unit/afterGraceInsideTuplet.test.js +0 -186
  79. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +0 -21
  80. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +0 -356
  81. package/lib/tests/unit/crossStaffDecoder.test.d.ts +0 -15
  82. package/lib/tests/unit/crossStaffDecoder.test.js +0 -147
  83. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +0 -1
  84. package/lib/tests/unit/crossStaffEdgeCases.test.js +0 -209
  85. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +0 -15
  86. package/lib/tests/unit/crossStaffMultiMeasure.test.js +0 -231
  87. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +0 -11
  88. package/lib/tests/unit/fullMeasureRestDecoder.test.js +0 -154
  89. package/lib/tests/unit/gptReviewIssues.test.d.ts +0 -8
  90. package/lib/tests/unit/gptReviewIssues.test.js +0 -240
  91. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +0 -13
  92. package/lib/tests/unit/parallelMusicDecoder.test.js +0 -261
  93. package/lib/tests/unit/partialWarning.test.d.ts +0 -4
  94. package/lib/tests/unit/partialWarning.test.js +0 -65
  95. package/lib/tests/unit/serializerRoundTrip.test.d.ts +0 -8
  96. package/lib/tests/unit/serializerRoundTrip.test.js +0 -263
  97. package/lib/tests/unit/staffInsideTuplet.test.d.ts +0 -25
  98. package/lib/tests/unit/staffInsideTuplet.test.js +0 -133
  99. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +0 -16
  100. package/lib/tests/unit/timesFirstNoteEscape.test.js +0 -152
  101. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +0 -17
  102. package/lib/tests/unit/tupletWithBaseDuration.test.js +0 -139
  103. package/lib/tests/unit/voiceStaffParsing.test.d.ts +0 -13
  104. package/lib/tests/unit/voiceStaffParsing.test.js +0 -118
@@ -1,186 +0,0 @@
1
- /**
2
- * Unit test: \afterGrace / \acciaccatura inside \times 2/3 must NOT leak
3
- * notes outside the tuplet wrapper.
4
- *
5
- * Bug: chopin-25-2.ly measure 68 (voice 1) has the pattern:
6
- *
7
- * \times 2/3 { bf8 [ c8 \afterGrace { \acciaccatura { ef8 } df8 ) ] } { c8 } }
8
- *
9
- * The decoder collects notes backwards when the Tuplet term fires.
10
- * The \afterGrace / \acciaccatura emit their notes through a different
11
- * listener path that bypasses the flat note stream, so bf8 and c8 remain
12
- * in voice.events outside the tuplet while only df8 ends up wrapped.
13
- *
14
- * Expected (measure 68 has 4 × \times 2/3 { 3 eighth notes }, 2/2 time):
15
- * total duration = 4 × 480 = 1920 ticks (exactly 2/2)
16
- *
17
- * Actual (bugged):
18
- * 3 correct tuplets (1440) + bf8(240) + c8(240) + \times 2/3{df8}(160)
19
- * = 2080 ticks → exceeds 1920 capacity
20
- *
21
- * Usage: npx tsx tests/unit/afterGraceInsideTuplet.test.ts
22
- */
23
- import { decode } from "../../source/lilylet/lilypondDecoder.js";
24
- import { serializeLilyletDoc } from "../../source/lilylet/serializer.js";
25
- let passed = 0;
26
- let failed = 0;
27
- function assert(condition, message) {
28
- if (condition) {
29
- console.log(` ✓ ${message}`);
30
- passed++;
31
- }
32
- else {
33
- console.error(` ✗ FAIL: ${message}`);
34
- failed++;
35
- }
36
- }
37
- const LY_BOILERPLATE = `
38
- \\version "2.22.0"
39
- \\language "english"
40
- \\header { tagline = ##f }
41
- \\layout { \\context { \\Score autoBeaming = ##f } }
42
- `;
43
- // Warm-up
44
- {
45
- const w = console.warn, a = console.assert;
46
- console.warn = () => { };
47
- console.assert = () => { };
48
- try {
49
- await decode('{ c }');
50
- }
51
- catch { }
52
- console.warn = w;
53
- console.assert = a;
54
- }
55
- const TPQN = 480;
56
- function measureDuration(events, mul = 1) {
57
- let total = 0;
58
- for (const e of events) {
59
- if (e.type === 'note' || e.type === 'rest') {
60
- if (!e.invisible && !e.grace) {
61
- const d = e.duration;
62
- let t = (TPQN * 4) / d.division;
63
- let dot = t / 2;
64
- for (let i = 0; i < d.dots; i++) {
65
- t += dot;
66
- dot /= 2;
67
- }
68
- total += Math.round(t * mul);
69
- }
70
- }
71
- else if (e.type === 'tuplet' || e.type === 'times') {
72
- const inner = mul * e.ratio.numerator / e.ratio.denominator;
73
- total += measureDuration(e.events, inner);
74
- }
75
- }
76
- return total;
77
- }
78
- // ─── Minimal reproduction of Chopin 25-2 m68 ─────────────────────────────────
79
- // 4 × \times 2/3 { 3 eighth notes } in 2/2 time = 4 × 480 = 1920 ticks.
80
- // Last group contains \afterGrace { \acciaccatura { ef8 } df8 } { c8 }.
81
- const LY_AFTER_GRACE_IN_TUPLET = LY_BOILERPLATE + `
82
- \\score {
83
- \\new Staff {
84
- \\new Voice {
85
- \\relative c' {
86
- \\time 2/2
87
- \\once \\omit TupletNumber \\times 2/3 { c8 [ c' bf ] }
88
- \\once \\omit TupletNumber \\times 2/3 { af8 [ g f ] }
89
- \\once \\omit TupletNumber \\times 2/3 { ef8 [ df c ] }
90
- \\once \\omit TupletNumber \\times 2/3 {
91
- bf8 [ c8
92
- \\afterGrace {
93
- \\acciaccatura { ef8 }
94
- df8
95
- }
96
- { c8 }
97
- }
98
- }
99
- }
100
- }
101
- \\layout {}
102
- }
103
- `;
104
- console.log('\nTest 1: measure duration must equal 2/2 capacity (1920 ticks)');
105
- console.log('─'.repeat(60));
106
- await (async () => {
107
- const doc = await decode(LY_AFTER_GRACE_IN_TUPLET);
108
- assert(doc.measures.length >= 1, `decoded 1+ measures (got ${doc.measures.length})`);
109
- if (!doc.measures[0])
110
- return;
111
- const voice = doc.measures[0].parts[0]?.voices[0];
112
- assert(!!voice, 'voice exists');
113
- if (!voice)
114
- return;
115
- const totalTicks = measureDuration(voice.events);
116
- const capacity = TPQN * 4 * 2 / 2; // 2/2 = 1920
117
- assert(totalTicks === capacity, `total duration ${totalTicks} ticks === 2/2 capacity ${capacity} ticks`);
118
- // All 4 groups must be wrapped in tuplets
119
- const tuplets = voice.events.filter(e => e.type === 'tuplet' || e.type === 'times');
120
- const bareNotes = voice.events.filter(e => e.type === 'note' && !e.grace);
121
- assert(tuplets.length === 4, `4 tuplet wrappers present (got ${tuplets.length})`);
122
- assert(bareNotes.length === 0, `no bare non-grace notes at top level (got ${bareNotes.length})`);
123
- // Last tuplet must contain 3 notes (bf, c, df — c8 afterGrace = grace, ignored)
124
- if (tuplets.length === 4) {
125
- const last = tuplets[3];
126
- const innerNotes = last.events.filter(e => e.type === 'note' && !e.grace);
127
- assert(innerNotes.length >= 2, `last tuplet has ≥2 non-grace notes (bf, c visible; df may be graced) — got ${innerNotes.length}`);
128
- }
129
- })();
130
- // ─── Test 2: simpler \afterGrace inside tuplet (no acciaccatura) ──────────────
131
- console.log('\nTest 2: simple \\afterGrace (no acciaccatura) inside \\times 2/3');
132
- console.log('─'.repeat(60));
133
- const LY_SIMPLE_AFTER_GRACE = LY_BOILERPLATE + `
134
- \\score {
135
- \\new Staff {
136
- \\new Voice {
137
- \\relative c' {
138
- \\time 2/2
139
- \\once \\omit TupletNumber \\times 2/3 { c8 d e }
140
- \\once \\omit TupletNumber \\times 2/3 { f8 g a }
141
- \\once \\omit TupletNumber \\times 2/3 { b8 c d }
142
- \\once \\omit TupletNumber \\times 2/3 {
143
- e8 f \\afterGrace g8 { f16 }
144
- }
145
- }
146
- }
147
- }
148
- \\layout {}
149
- }
150
- `;
151
- await (async () => {
152
- const doc = await decode(LY_SIMPLE_AFTER_GRACE);
153
- if (!doc.measures[0]) {
154
- assert(false, 'decoded measure');
155
- return;
156
- }
157
- const voice = doc.measures[0].parts[0]?.voices[0];
158
- if (!voice) {
159
- assert(false, 'voice exists');
160
- return;
161
- }
162
- const totalTicks = measureDuration(voice.events);
163
- const capacity = TPQN * 4 * 2 / 2;
164
- const bareNotes = voice.events.filter(e => e.type === 'note' && !e.grace);
165
- assert(bareNotes.length === 0, `simple \\afterGrace inside tuplet: no bare notes at top level (got ${bareNotes.length})`);
166
- assert(totalTicks <= capacity, `duration ${totalTicks} ≤ capacity ${capacity}`);
167
- })();
168
- // ─── Test 3: serialized lyl tick count (via visualize logic) ─────────────────
169
- console.log('\nTest 3: serialized lyl round-trips to correct measure duration');
170
- console.log('─'.repeat(60));
171
- await (async () => {
172
- const doc = await decode(LY_AFTER_GRACE_IN_TUPLET);
173
- const lyl = serializeLilyletDoc(doc);
174
- console.log(' lyl m1:', lyl.split('\n').find(l => l.includes('%1'))?.trim() ?? '(not found)');
175
- // lyl should NOT have bare non-grace eighth notes at the measure level
176
- // (they should all be inside \times or \tuplet wrappers)
177
- const m1line = lyl.split('|')[0] ?? '';
178
- const hasBareEighths = /(?<!\\grace\s)\b[a-g][',]*8\b/.test(m1line.replace(/\\times[^{]*\{[^}]*\}/g, '').replace(/\\grace\s+\S+/g, ''));
179
- assert(!hasBareEighths, `serialized lyl measure 1 has no bare eighth notes outside tuplet wrappers`);
180
- })();
181
- // ─── summary ─────────────────────────────────────────────────────────────────
182
- console.log(`\n${'═'.repeat(50)}`);
183
- if (failed > 0)
184
- console.log(`⚠️ ${failed} FAILED — \\afterGrace inside \\times 2/3 causes note leakage`);
185
- console.log(`Total: ${passed + failed} Passed: ${passed} Failed: ${failed}`);
186
- process.exit(failed > 0 ? 1 : 0);
@@ -1,21 +0,0 @@
1
- /**
2
- * Unit test: \change Staff before \times tuplet preserves staff assignment across measures.
3
- *
4
- * Regression guard for the bug where a voice-level `\change Staff = "N"` immediately
5
- * preceding a `\times` tuplet ends up AFTER the tuplet in the flat voice event list,
6
- * causing the serializer to emit the wrong \staff "N" for the following measure when
7
- * the carry-over staff equals the track's default staff (carryStaff === trackStaff).
8
- *
9
- * Real-world case: rachmaninoff-3-2 measure 42, PartPOneVoiceThree (defined in
10
- * \context Staff = "2"). The measure ends on staff=2, so carryStaff=2=trackStaff.
11
- * Measure 42 begins with `\change Staff = "1" \times 2/3 { ... }`. The expected lyl
12
- * output is `\staff "1" \tuplet 3/2 { ... }`, but the actual output is
13
- * `\staff "2" \tuplet 3/2 { ... }` because:
14
- * 1. lotus flat-term-list places \change Staff AFTER the tuplet body notes
15
- * 2. the carryStaff === trackStaff guard skips all carry-over logic
16
- * 3. effectiveInitialStaff defaults to trackStaff (=2) since the first
17
- * voice event is the tuplet itself (a musical type → scan stops)
18
- *
19
- * Usage: npx tsx tests/unit/changeStaffBeforeTuplet.test.ts
20
- */
21
- export {};
@@ -1,356 +0,0 @@
1
- /**
2
- * Unit test: \change Staff before \times tuplet preserves staff assignment across measures.
3
- *
4
- * Regression guard for the bug where a voice-level `\change Staff = "N"` immediately
5
- * preceding a `\times` tuplet ends up AFTER the tuplet in the flat voice event list,
6
- * causing the serializer to emit the wrong \staff "N" for the following measure when
7
- * the carry-over staff equals the track's default staff (carryStaff === trackStaff).
8
- *
9
- * Real-world case: rachmaninoff-3-2 measure 42, PartPOneVoiceThree (defined in
10
- * \context Staff = "2"). The measure ends on staff=2, so carryStaff=2=trackStaff.
11
- * Measure 42 begins with `\change Staff = "1" \times 2/3 { ... }`. The expected lyl
12
- * output is `\staff "1" \tuplet 3/2 { ... }`, but the actual output is
13
- * `\staff "2" \tuplet 3/2 { ... }` because:
14
- * 1. lotus flat-term-list places \change Staff AFTER the tuplet body notes
15
- * 2. the carryStaff === trackStaff guard skips all carry-over logic
16
- * 3. effectiveInitialStaff defaults to trackStaff (=2) since the first
17
- * voice event is the tuplet itself (a musical type → scan stops)
18
- *
19
- * Usage: npx tsx tests/unit/changeStaffBeforeTuplet.test.ts
20
- */
21
- import { decode } from "../../source/lilylet/lilypondDecoder.js";
22
- import { serializeLilyletDoc } from "../../source/lilylet/serializer.js";
23
- import { parseCode } from "../../source/lilylet/parser.js";
24
- // ─── helpers ────────────────────────────────────────────────────────────────
25
- let passed = 0;
26
- let failed = 0;
27
- function assert(condition, message) {
28
- if (condition) {
29
- console.log(` ✓ ${message}`);
30
- passed++;
31
- }
32
- else {
33
- console.error(` ✗ FAIL: ${message}`);
34
- failed++;
35
- }
36
- }
37
- /** Return the initial staff of a voice (before any events). */
38
- function getInitialStaff(voice) {
39
- return voice.staff || 1;
40
- }
41
- // ─── Minimal reproduction case ───────────────────────────────────────────────
42
- //
43
- // Structure:
44
- // \context Staff = "2" voice with:
45
- // measure 1: \change Staff = "2" c1 → ends on staff 2
46
- // measure 2: \change Staff = "1" \times 2/3 { c8 d e } ...
47
- //
48
- // Expected: lyl measure 2 voice starts with \staff "1"
49
- // Bug: lyl measure 2 voice starts with \staff "2"
50
- const LY_BOILERPLATE = `
51
- \\version "2.22.0"
52
- \\language "english"
53
- \\header { tagline = ##f }
54
- #(set-global-staff-size 20)
55
- \\paper { paper-width = 210\\mm paper-height = 297\\mm ragged-last = ##t }
56
- \\layout { \\context { \\Score autoBeaming = ##f } }
57
- `;
58
- const LY_CHANGE_STAFF_BEFORE_TUPLET = LY_BOILERPLATE + `
59
- \\score {
60
- \\new PianoStaff <<
61
- \\context Staff = "1" {
62
- \\time 4/4 c'1 | c'1 |
63
- }
64
- \\context Staff = "2" <<
65
- \\new Voice {
66
- \\time 4/4
67
- \\change Staff = "2" c1 |
68
- \\change Staff = "1" \\times 2/3 { c'8 d' e' }
69
- \\change Staff = "2" \\times 2/3 { c8 d e }
70
- \\change Staff = "1" \\times 2/3 { f'8 g' a' }
71
- \\change Staff = "2" \\times 2/3 { f8 g a } |
72
- }
73
- >>
74
- >>
75
- \\layout { }
76
- }
77
- `;
78
- console.log('\nTest: \\change Staff = "1" before \\times tuplet (cross-measure carry)');
79
- console.log('─'.repeat(60));
80
- await (async () => {
81
- // 1. Decode
82
- const doc = await decode(LY_CHANGE_STAFF_BEFORE_TUPLET);
83
- assert(doc.measures.length >= 2, `decoded at least 2 measures (got ${doc.measures.length})`);
84
- if (doc.measures.length < 2)
85
- return;
86
- // 2. Serialize to lyl
87
- const lyl = serializeLilyletDoc(doc);
88
- assert(typeof lyl === 'string' && lyl.length > 0, 'serialized to non-empty lyl string');
89
- // 3. Parse the lyl back
90
- const parsed = parseCode(lyl);
91
- assert(parsed.measures.length >= 2, `parsed lyl has at least 2 measures (got ${parsed.measures.length})`);
92
- if (parsed.measures.length < 2)
93
- return;
94
- // 4. Check measure 1: the cross-staff voice should start on staff 2 (the track default)
95
- const m1 = parsed.measures[0];
96
- // Find the voice that has cross-staff content (staff=2 track, but switching to 1)
97
- let m1CrossVoice;
98
- for (const part of m1.parts) {
99
- for (const v of part.voices) {
100
- // The cross-staff voice has staff=2 as initial (ends on staff=2 in m1)
101
- if ((v.staff === 2) && v.events.some((e) => e.type === 'note' || e.type === 'tuplet' || e.type === 'times')) {
102
- m1CrossVoice = v;
103
- break;
104
- }
105
- }
106
- if (m1CrossVoice)
107
- break;
108
- }
109
- assert(!!m1CrossVoice, 'measure 1 has a cross-staff voice (staff=2)');
110
- // 5. Check measure 2: the cross-staff voice should start on staff 1
111
- // because \change Staff = "1" precedes the first \times tuplet
112
- const m2 = parsed.measures[1];
113
- let m2CrossVoice;
114
- for (const part of m2.parts) {
115
- for (const v of part.voices) {
116
- if ((v.staff === 1 || v.staff === 2) && v.events.some((e) => e.type === 'tuplet' || e.type === 'times')) {
117
- m2CrossVoice = v;
118
- break;
119
- }
120
- }
121
- if (m2CrossVoice)
122
- break;
123
- }
124
- assert(!!m2CrossVoice, 'measure 2 has a voice with tuplet content');
125
- if (m2CrossVoice) {
126
- const initStaff = getInitialStaff(m2CrossVoice);
127
- assert(initStaff === 1, `measure 2 cross-staff voice starts on staff 1 (\\change Staff = "1" before \\times) — got staff=${initStaff}`);
128
- }
129
- // 6. Cross-check via lyl content: should contain \staff "1" before the first tuplet
130
- const m2Lines = lyl.split('\n').filter(l => l.includes('%2') || l.includes('\\times'));
131
- const hasTupletLine = lyl.includes('\\times 2/3') || lyl.includes('\\tuplet 3/2');
132
- assert(hasTupletLine, 'serialized lyl contains tuplet notation');
133
- // The specific check: find the voice line for measure 2 with tuplets
134
- // It should start with \staff "1", not \staff "2"
135
- const lylLines = lyl.split('\n');
136
- // Find lines with tuplet content (the cross-staff voice in measure 2)
137
- const tupletLines = lylLines.filter(l => (l.includes('\\times') || l.includes('\\tuplet')) &&
138
- l.includes('c\'') || l.includes("d'") || l.includes("e'") || l.includes("f'"));
139
- assert(tupletLines.length > 0, 'found tuplet line with treble-range notes in lyl');
140
- if (tupletLines.length > 0) {
141
- const firstTupletLine = tupletLines[0];
142
- assert(firstTupletLine.startsWith('\\staff "1"'), `tuplet line starts with \\staff "1" (got: "${firstTupletLine.slice(0, 30)}...")`);
143
- }
144
- })();
145
- // ─── Test 2: rachmaninoff-style — 4-tuplet measure ending on staff=2 ─────────
146
- //
147
- // Closer to the real failure: measure 1 has 4 triplets each alternating
148
- // \change Staff internally, ending on staff=2 (carryStaff=2=trackStaff).
149
- // Measure 2 opens with \change Staff = "1" \times 2/3 {...}.
150
- // Expected lyl measure 2 first voice: \staff "1"
151
- // Bug: \staff "2"
152
- const LY_RACHMANINOFF_STYLE = LY_BOILERPLATE + `
153
- \\score {
154
- \\new PianoStaff <<
155
- \\context Staff = "1" {
156
- \\time 4/4 c'1 | c'1 |
157
- }
158
- \\context Staff = "2" <<
159
- \\new Voice {
160
- \\time 4/4
161
- \\change Staff = "1" \\times 2/3 { c'8 [ \\change Staff = "2" c8 \\change Staff = "1" c'8 ] }
162
- \\change Staff = "2" \\times 2/3 { e8 [ \\change Staff = "1" e'8 \\change Staff = "2" e8 ] }
163
- \\change Staff = "1" \\times 2/3 { g'8 [ \\change Staff = "2" g8 \\change Staff = "1" g'8 ] }
164
- \\change Staff = "2" \\times 2/3 { a8 [ \\change Staff = "1" a'8 \\change Staff = "2" a8 ] } |
165
- \\change Staff = "1" \\times 2/3 { b'8 [ \\change Staff = "2" b8 \\change Staff = "1" b'8 ] }
166
- \\change Staff = "2" \\times 2/3 { d8 [ \\change Staff = "1" d'8 \\change Staff = "2" d8 ] }
167
- \\change Staff = "1" \\times 2/3 { f'8 [ \\change Staff = "2" f8 \\change Staff = "1" f'8 ] }
168
- \\change Staff = "2" \\times 2/3 { g8 [ \\change Staff = "1" g'8 \\change Staff = "2" g8 ] } |
169
- }
170
- >>
171
- >>
172
- \\layout { }
173
- }
174
- `;
175
- console.log('\nTest 2: rachmaninoff-style (4 tuplets/measure, cross-staff, ending staff=2)');
176
- console.log('─'.repeat(60));
177
- await (async () => {
178
- const doc = await decode(LY_RACHMANINOFF_STYLE);
179
- assert(doc.measures.length >= 2, `decoded ≥ 2 measures (got ${doc.measures.length})`);
180
- if (doc.measures.length < 2)
181
- return;
182
- const lyl = serializeLilyletDoc(doc);
183
- const parsed = parseCode(lyl);
184
- assert(parsed.measures.length >= 2, `parsed lyl has ≥ 2 measures (got ${parsed.measures.length})`);
185
- if (parsed.measures.length < 2)
186
- return;
187
- // In measure 2, the cross-staff voice (in Staff "2") should start on staff=1
188
- // because \change Staff = "1" precedes the first \times tuplet.
189
- const m2 = parsed.measures[1];
190
- let m2CrossVoice;
191
- for (const part of m2.parts) {
192
- for (const v of part.voices) {
193
- if (v.events.some((e) => e.type === 'tuplet' || e.type === 'times')) {
194
- m2CrossVoice = v;
195
- break;
196
- }
197
- }
198
- if (m2CrossVoice)
199
- break;
200
- }
201
- assert(!!m2CrossVoice, 'measure 2 has a voice with tuplet content');
202
- if (m2CrossVoice) {
203
- const initStaff = m2CrossVoice.staff || 1;
204
- assert(initStaff === 1, `measure 2 voice starts on staff 1 after 4-tuplet measure ending staff=2 — got staff=${initStaff}`);
205
- }
206
- })();
207
- // ─── Test 3: two-PianoStaff score, cross-staff voice in Piano I ──────────────
208
- //
209
- // Rachmaninoff has two PianoStaffs. Piano I, Staff "2" has a cross-staff voice
210
- // that plays 4-tuplet patterns across many measures. Adding a second PianoStaff
211
- // (Piano II) is the structural difference from Test 2 that triggers the regression.
212
- //
213
- // Minimal reproduction: 2 PianoStaffs, cross-staff voice in Piano I runs for 3+
214
- // measures ending staff=2, then one more measure should start on staff=1.
215
- const LY_TWO_PIANOSTAFFS = LY_BOILERPLATE + `
216
- \\score {
217
- <<
218
- \\new PianoStaff \\with { instrumentName = "Piano I" } <<
219
- \\context Staff = "1" { \\time 4/4 c'1 | c'1 | c'1 | c'1 | }
220
- \\context Staff = "2" <<
221
- \\new Voice {
222
- \\change Staff = "1" \\times 2/3 { c'8 [ \\change Staff = "2" c8 \\change Staff = "1" c'8 ] }
223
- \\change Staff = "2" \\times 2/3 { e8 [ \\change Staff = "1" e'8 \\change Staff = "2" e8 ] }
224
- \\change Staff = "1" \\times 2/3 { g'8 [ \\change Staff = "2" g8 \\change Staff = "1" g'8 ] }
225
- \\change Staff = "2" \\times 2/3 { a8 [ \\change Staff = "1" a'8 \\change Staff = "2" a8 ] } |
226
- \\change Staff = "1" \\times 2/3 { c'8 [ \\change Staff = "2" c8 \\change Staff = "1" c'8 ] }
227
- \\change Staff = "2" \\times 2/3 { e8 [ \\change Staff = "1" e'8 \\change Staff = "2" e8 ] }
228
- \\change Staff = "1" \\times 2/3 { g'8 [ \\change Staff = "2" g8 \\change Staff = "1" g'8 ] }
229
- \\change Staff = "2" \\times 2/3 { a8 [ \\change Staff = "1" a'8 \\change Staff = "2" a8 ] } |
230
- \\change Staff = "1" \\times 2/3 { c'8 [ \\change Staff = "2" c8 \\change Staff = "1" c'8 ] }
231
- \\change Staff = "2" \\times 2/3 { e8 [ \\change Staff = "1" e'8 \\change Staff = "2" e8 ] }
232
- \\change Staff = "1" \\times 2/3 { g'8 [ \\change Staff = "2" g8 \\change Staff = "1" g'8 ] }
233
- \\change Staff = "2" \\times 2/3 { a8 [ \\change Staff = "1" a'8 \\change Staff = "2" a8 ] } |
234
- \\change Staff = "1" \\times 2/3 { b'8 [ \\change Staff = "2" b8 \\change Staff = "1" b'8 ] }
235
- \\change Staff = "2" \\times 2/3 { d8 [ \\change Staff = "1" d'8 \\change Staff = "2" d8 ] }
236
- \\change Staff = "1" \\times 2/3 { f'8 [ \\change Staff = "2" f8 \\change Staff = "1" f'8 ] }
237
- \\change Staff = "2" \\times 2/3 { g8 [ \\change Staff = "1" g'8 \\change Staff = "2" g8 ] } |
238
- }
239
- >>
240
- >>
241
- \\new PianoStaff \\with { instrumentName = "Piano II" } <<
242
- \\context Staff = "1" { c'1 | c'1 | c'1 | c'1 | }
243
- \\context Staff = "2" { c,,1 | c,,1 | c,,1 | c,,1 | }
244
- >>
245
- >>
246
- \\layout { }
247
- }
248
- `;
249
- console.log('\nTest 3: two-PianoStaff score — cross-staff voice in Piano I');
250
- console.log('─'.repeat(60));
251
- await (async () => {
252
- const doc = await decode(LY_TWO_PIANOSTAFFS);
253
- assert(doc.measures.length >= 4, `decoded ≥ 4 measures (got ${doc.measures.length})`);
254
- if (doc.measures.length < 4)
255
- return;
256
- const lyl = serializeLilyletDoc(doc);
257
- const parsed = parseCode(lyl);
258
- assert(parsed.measures.length >= 4, `parsed lyl has ≥ 4 measures (got ${parsed.measures.length})`);
259
- if (parsed.measures.length < 4)
260
- return;
261
- // Every measure should have its cross-staff voice start on staff=1
262
- // (because \change Staff = "1" precedes the first tuplet every time)
263
- for (let mi = 0; mi < 4; mi++) {
264
- const m = parsed.measures[mi];
265
- let crossVoice;
266
- for (const part of m.parts) {
267
- for (const v of part.voices) {
268
- if (v.events.some((e) => e.type === 'tuplet' || e.type === 'times')) {
269
- crossVoice = v;
270
- break;
271
- }
272
- }
273
- if (crossVoice)
274
- break;
275
- }
276
- if (!crossVoice)
277
- continue;
278
- const initStaff = crossVoice.staff || 1;
279
- assert(initStaff === 1, `measure ${mi} cross-staff voice starts staff=1 in two-PianoStaff score — got staff=${initStaff}`);
280
- }
281
- })();
282
- // ─── Test 4: spacer measure then bare \times (no explicit \change Staff) ─────
283
- //
284
- // Distilled from rachmaninoff-3-2.ly L217-237. PartPOneVoiceThree:
285
- //
286
- // measure 1: \change Staff = "2" s4 \change Staff = "1" \times 2/3 {...} s4
287
- // → ends on staff=1 (carryStaff = 1 after this measure)
288
- // measure 2: s1 → spacer whole measure, no staff change
289
- // → carryStaff stays 1
290
- // measure 3: \times 2/3 {...} → bare tuplet, NO leading \change Staff
291
- // → should inherit carryStaff=1, output \staff "1"
292
- // Bug: outputs \staff "2" (carryStaff mechanism broken)
293
- const LY_SPACER_THEN_BARE_TUPLET = LY_BOILERPLATE + `
294
- \\score {
295
- \\new PianoStaff \\with { instrumentName = "Piano I" } <<
296
- \\context Staff = "1" { \\time 4/4 c'1 | c'1 | c'1 | }
297
- \\context Staff = "2" <<
298
- \\new Voice {
299
- \\change Staff = "2" s4
300
- \\change Staff = "1" \\times 2/3 { c'8 [ \\change Staff = "2" c8 \\change Staff = "1" c'8 ] }
301
- \\change Staff = "2" \\times 2/3 { e8 [ \\change Staff = "1" e'8 \\change Staff = "2" e8 ] }
302
- \\change Staff = "1" \\times 2/3 { g'8 [ \\change Staff = "2" g8 \\change Staff = "1" g'8 ] }
303
- s4 |
304
- s1 |
305
- \\times 2/3 { c'8 [ \\change Staff = "2" c8 \\change Staff = "1" c'8 ] }
306
- \\change Staff = "2" \\times 2/3 { e8 [ \\change Staff = "1" e'8 \\change Staff = "2" e8 ] }
307
- \\change Staff = "1" \\times 2/3 { g'8 [ \\change Staff = "2" g8 \\change Staff = "1" g'8 ] }
308
- \\change Staff = "2" \\times 2/3 { a8 [ \\change Staff = "1" a'8 \\change Staff = "2" a8 ] } |
309
- }
310
- >>
311
- >>
312
- \\new PianoStaff \\with { instrumentName = "Piano II" } <<
313
- \\context Staff = "1" { c'1 | c'1 | c'1 | }
314
- \\context Staff = "2" { c,,1 | c,,1 | c,,1 | }
315
- >>
316
- \\layout { }
317
- }
318
- `;
319
- console.log('\nTest 4: spacer measure then bare \\times (carryStaff=1 should propagate)');
320
- console.log('─'.repeat(60));
321
- await (async () => {
322
- const doc = await decode(LY_SPACER_THEN_BARE_TUPLET);
323
- assert(doc.measures.length >= 3, `decoded ≥ 3 measures (got ${doc.measures.length})`);
324
- if (doc.measures.length < 3)
325
- return;
326
- const lyl = serializeLilyletDoc(doc);
327
- const parsed = parseCode(lyl);
328
- assert(parsed.measures.length >= 3, `parsed lyl has ≥ 3 measures (got ${parsed.measures.length})`);
329
- if (parsed.measures.length < 3)
330
- return;
331
- // measure 2 (0-indexed) = the bare-\times measure.
332
- // carryStaff from m1 = 1 (last \change Staff = "1"), m2 = s1 (no change).
333
- // Carry-over of staff=1 should be prepended → voice starts \staff "1".
334
- const m2 = parsed.measures[2];
335
- let crossVoice;
336
- for (const part of m2.parts) {
337
- for (const v of part.voices) {
338
- if (v.events.some((e) => e.type === 'tuplet' || e.type === 'times')) {
339
- crossVoice = v;
340
- break;
341
- }
342
- }
343
- if (crossVoice)
344
- break;
345
- }
346
- assert(!!crossVoice, 'measure 2 has a voice with tuplet content');
347
- if (crossVoice) {
348
- const initStaff = crossVoice.staff || 1;
349
- assert(initStaff === 1, `measure 2 (bare \\times after spacer) voice starts staff=1 — got staff=${initStaff}`);
350
- }
351
- })();
352
- // ─── summary ────────────────────────────────────────────────────────────────
353
- console.log('');
354
- console.log(`Results: ${passed} passed, ${failed} failed`);
355
- if (failed > 0)
356
- process.exit(1);
@@ -1,15 +0,0 @@
1
- /**
2
- * Unit test: \change Staff handling in the LilyPond decoder.
3
- *
4
- * Regression guard for the bug where \change Staff commands are ignored during
5
- * .ly → JSON decoding, causing notes that should be on staff 2 (bass) to remain
6
- * on staff 1 (treble) in the lilylet output.
7
- *
8
- * Background: BWV-787 m1 fails because the decoder ignores all \change Staff
9
- * commands (lilypondDecoder.ts: "Ignore \change Staff commands - staff is fixed
10
- * per track"). Notes that cross staves get wrong tick assignments when
11
- * regulateLilylet matches them against spartito events.
12
- *
13
- * Usage: npx tsx tests/unit/crossStaffDecoder.test.ts
14
- */
15
- export {};