@k-l-lambda/lilylet 0.1.70 → 0.1.72
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/gmInstruments.d.ts +1 -0
- package/lib/gmInstruments.js +1 -0
- package/lib/highlight.d.ts +1 -0
- package/lib/highlight.js +1 -0
- package/lib/lilylet/abcDecoder.js +16 -7
- package/lib/lilylet/gmInstruments.d.ts +1 -0
- package/lib/lilylet/gmInstruments.js +295 -0
- package/lib/lilylet/highlight.d.ts +29 -0
- package/lib/lilylet/highlight.js +145 -0
- package/lib/lilylet/meiEncoder.js +126 -14
- package/lib/lilylet/staffLayout.d.ts +5 -0
- package/lib/lilylet/staffLayout.js +62 -0
- package/package.json +8 -2
- package/source/lilylet/abcDecoder.ts +14 -7
- package/source/lilylet/gmInstruments.ts +305 -0
- package/source/lilylet/highlight.ts +192 -0
- package/source/lilylet/meiEncoder.ts +135 -11
- package/source/lilylet/staffLayout.ts +76 -0
- package/lib/source/abc/abc.d.ts +0 -102
- package/lib/source/abc/abc.js +0 -25
- package/lib/source/abc/parser.d.ts +0 -3
- package/lib/source/abc/parser.js +0 -6
- package/lib/source/lilylet/abcDecoder.d.ts +0 -25
- package/lib/source/lilylet/abcDecoder.js +0 -1035
- package/lib/source/lilylet/index.d.ts +0 -10
- package/lib/source/lilylet/index.js +0 -10
- package/lib/source/lilylet/lilypondDecoder.d.ts +0 -29
- package/lib/source/lilylet/lilypondDecoder.js +0 -1223
- package/lib/source/lilylet/lilypondEncoder.d.ts +0 -34
- package/lib/source/lilylet/lilypondEncoder.js +0 -893
- package/lib/source/lilylet/meiEncoder.d.ts +0 -8
- package/lib/source/lilylet/meiEncoder.js +0 -1985
- package/lib/source/lilylet/musicXmlDecoder.d.ts +0 -20
- package/lib/source/lilylet/musicXmlDecoder.js +0 -1195
- package/lib/source/lilylet/musicXmlEncoder.d.ts +0 -15
- package/lib/source/lilylet/musicXmlEncoder.js +0 -701
- package/lib/source/lilylet/musicXmlTypes.d.ts +0 -199
- package/lib/source/lilylet/musicXmlTypes.js +0 -7
- package/lib/source/lilylet/musicXmlUtils.d.ts +0 -92
- package/lib/source/lilylet/musicXmlUtils.js +0 -469
- package/lib/source/lilylet/parser.d.ts +0 -14
- package/lib/source/lilylet/parser.js +0 -161
- package/lib/source/lilylet/serializer.d.ts +0 -11
- package/lib/source/lilylet/serializer.js +0 -791
- package/lib/source/lilylet/types.d.ts +0 -253
- package/lib/source/lilylet/types.js +0 -100
- package/lib/tests/abc-abcjs-parse.d.ts +0 -8
- package/lib/tests/abc-abcjs-parse.js +0 -90
- package/lib/tests/abc-abcjs-svg.d.ts +0 -1
- package/lib/tests/abc-abcjs-svg.js +0 -143
- package/lib/tests/abc-decoder.d.ts +0 -1
- package/lib/tests/abc-decoder.js +0 -67
- package/lib/tests/abc-mei-compare.d.ts +0 -1
- package/lib/tests/abc-mei-compare.js +0 -525
- package/lib/tests/auto-beam.d.ts +0 -9
- package/lib/tests/auto-beam.js +0 -151
- package/lib/tests/computeMeiHashes.d.ts +0 -1
- package/lib/tests/computeMeiHashes.js +0 -87
- package/lib/tests/encoder-mutation.d.ts +0 -9
- package/lib/tests/encoder-mutation.js +0 -110
- package/lib/tests/gpt-review-issues.d.ts +0 -5
- package/lib/tests/gpt-review-issues.js +0 -255
- package/lib/tests/json-to-lyl.d.ts +0 -1
- package/lib/tests/json-to-lyl.js +0 -18
- package/lib/tests/lilypond-roundtrip.d.ts +0 -7
- package/lib/tests/lilypond-roundtrip.js +0 -558
- package/lib/tests/lilypondDecoder.d.ts +0 -6
- package/lib/tests/lilypondDecoder.js +0 -95
- package/lib/tests/ly-to-lyl.d.ts +0 -1
- package/lib/tests/ly-to-lyl.js +0 -12
- package/lib/tests/mei.d.ts +0 -1
- package/lib/tests/mei.js +0 -278
- package/lib/tests/musicxml-decoder.d.ts +0 -4
- package/lib/tests/musicxml-decoder.js +0 -61
- package/lib/tests/musicxml-detail.d.ts +0 -4
- package/lib/tests/musicxml-detail.js +0 -85
- package/lib/tests/musicxml-fprod.d.ts +0 -9
- package/lib/tests/musicxml-fprod.js +0 -153
- package/lib/tests/musicxml-roundtrip.d.ts +0 -7
- package/lib/tests/musicxml-roundtrip.js +0 -296
- package/lib/tests/musicxml-to-mei.d.ts +0 -6
- package/lib/tests/musicxml-to-mei.js +0 -115
- package/lib/tests/parser.d.ts +0 -1
- package/lib/tests/parser.js +0 -17
- package/lib/tests/render-k283.d.ts +0 -1
- package/lib/tests/render-k283.js +0 -33
- package/lib/tests/render-lyl.d.ts +0 -1
- package/lib/tests/render-lyl.js +0 -35
- package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +0 -23
- package/lib/tests/unit/afterGraceInsideTuplet.test.js +0 -186
- package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +0 -21
- package/lib/tests/unit/changeStaffBeforeTuplet.test.js +0 -356
- package/lib/tests/unit/crossStaffDecoder.test.d.ts +0 -15
- package/lib/tests/unit/crossStaffDecoder.test.js +0 -147
- package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +0 -1
- package/lib/tests/unit/crossStaffEdgeCases.test.js +0 -209
- package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +0 -15
- package/lib/tests/unit/crossStaffMultiMeasure.test.js +0 -231
- package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +0 -11
- package/lib/tests/unit/fullMeasureRestDecoder.test.js +0 -154
- package/lib/tests/unit/gptReviewIssues.test.d.ts +0 -8
- package/lib/tests/unit/gptReviewIssues.test.js +0 -240
- package/lib/tests/unit/parallelMusicDecoder.test.d.ts +0 -13
- package/lib/tests/unit/parallelMusicDecoder.test.js +0 -261
- package/lib/tests/unit/partialWarning.test.d.ts +0 -4
- package/lib/tests/unit/partialWarning.test.js +0 -65
- package/lib/tests/unit/serializerRoundTrip.test.d.ts +0 -8
- package/lib/tests/unit/serializerRoundTrip.test.js +0 -263
- package/lib/tests/unit/staffInsideTuplet.test.d.ts +0 -25
- package/lib/tests/unit/staffInsideTuplet.test.js +0 -133
- package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +0 -16
- package/lib/tests/unit/timesFirstNoteEscape.test.js +0 -152
- package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +0 -17
- package/lib/tests/unit/tupletWithBaseDuration.test.js +0 -139
- package/lib/tests/unit/voiceStaffParsing.test.d.ts +0 -13
- 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 {};
|