@k-l-lambda/lilylet 0.1.62 → 0.1.64
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/abc/grammar.jison.js +300 -187
- package/lib/lilylet/abcDecoder.js +40 -12
- package/lib/lilylet/lilypondEncoder.js +3 -0
- package/lib/lilylet/meiEncoder.js +87 -48
- package/lib/source/abc/abc.d.ts +102 -0
- package/lib/source/abc/abc.js +25 -0
- package/lib/source/abc/parser.d.ts +3 -0
- package/lib/source/abc/parser.js +6 -0
- package/lib/source/lilylet/abcDecoder.d.ts +25 -0
- package/lib/source/lilylet/abcDecoder.js +1035 -0
- package/lib/source/lilylet/index.d.ts +10 -0
- package/lib/source/lilylet/index.js +10 -0
- package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/source/lilylet/lilypondDecoder.js +1223 -0
- package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/source/lilylet/lilypondEncoder.js +893 -0
- package/lib/source/lilylet/meiEncoder.d.ts +8 -0
- package/lib/source/lilylet/meiEncoder.js +1985 -0
- package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/source/lilylet/musicXmlEncoder.js +701 -0
- package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/source/lilylet/musicXmlTypes.js +7 -0
- package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/source/lilylet/musicXmlUtils.js +469 -0
- package/lib/source/lilylet/parser.d.ts +14 -0
- package/lib/source/lilylet/parser.js +161 -0
- package/lib/source/lilylet/serializer.d.ts +11 -0
- package/lib/source/lilylet/serializer.js +791 -0
- package/lib/source/lilylet/types.d.ts +253 -0
- package/lib/source/lilylet/types.js +100 -0
- package/lib/tests/abc-abcjs-parse.d.ts +8 -0
- package/lib/tests/abc-abcjs-parse.js +90 -0
- package/lib/tests/abc-abcjs-svg.d.ts +1 -0
- package/lib/tests/abc-abcjs-svg.js +143 -0
- package/lib/tests/abc-decoder.d.ts +1 -0
- package/lib/tests/abc-decoder.js +67 -0
- package/lib/tests/abc-mei-compare.d.ts +1 -0
- package/lib/tests/abc-mei-compare.js +525 -0
- package/lib/tests/auto-beam.d.ts +9 -0
- package/lib/tests/auto-beam.js +151 -0
- package/lib/tests/computeMeiHashes.d.ts +1 -0
- package/lib/tests/computeMeiHashes.js +87 -0
- package/lib/tests/encoder-mutation.d.ts +9 -0
- package/lib/tests/encoder-mutation.js +110 -0
- package/lib/tests/gpt-review-issues.d.ts +5 -0
- package/lib/tests/gpt-review-issues.js +255 -0
- package/lib/tests/json-to-lyl.d.ts +1 -0
- package/lib/tests/json-to-lyl.js +18 -0
- package/lib/tests/lilypond-roundtrip.d.ts +7 -0
- package/lib/tests/lilypond-roundtrip.js +558 -0
- package/lib/tests/lilypondDecoder.d.ts +6 -0
- package/lib/tests/lilypondDecoder.js +95 -0
- package/lib/tests/ly-to-lyl.d.ts +1 -0
- package/lib/tests/ly-to-lyl.js +12 -0
- package/lib/tests/mei.d.ts +1 -0
- package/lib/tests/mei.js +278 -0
- package/lib/tests/musicxml-decoder.d.ts +4 -0
- package/lib/tests/musicxml-decoder.js +61 -0
- package/lib/tests/musicxml-detail.d.ts +4 -0
- package/lib/tests/musicxml-detail.js +85 -0
- package/lib/tests/musicxml-fprod.d.ts +9 -0
- package/lib/tests/musicxml-fprod.js +153 -0
- package/lib/tests/musicxml-roundtrip.d.ts +7 -0
- package/lib/tests/musicxml-roundtrip.js +296 -0
- package/lib/tests/musicxml-to-mei.d.ts +6 -0
- package/lib/tests/musicxml-to-mei.js +115 -0
- package/lib/tests/parser.d.ts +1 -0
- package/lib/tests/parser.js +17 -0
- package/lib/tests/render-k283.d.ts +1 -0
- package/lib/tests/render-k283.js +33 -0
- package/lib/tests/render-lyl.d.ts +1 -0
- package/lib/tests/render-lyl.js +35 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
- package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
- package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
- package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
- package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
- package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
- package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
- package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
- package/lib/tests/unit/gptReviewIssues.test.js +240 -0
- package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
- package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
- package/lib/tests/unit/partialWarning.test.d.ts +4 -0
- package/lib/tests/unit/partialWarning.test.js +65 -0
- package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
- package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
- package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
- package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
- package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
- package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
- package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
- package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
- package/package.json +5 -2
- package/source/abc/abc.jison +90 -15
- package/source/abc/grammar.jison.js +300 -187
- package/source/lilylet/abcDecoder.ts +42 -14
- package/source/lilylet/lilypondEncoder.ts +2 -0
- package/source/lilylet/meiEncoder.ts +95 -48
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MusicXML Roundtrip Test
|
|
3
|
+
*
|
|
4
|
+
* Tests the lilylet -> musicxml -> lilylet conversion cycle.
|
|
5
|
+
* Compares the output of two conversions to verify consistency.
|
|
6
|
+
*/
|
|
7
|
+
import * as fs from "fs";
|
|
8
|
+
import * as path from "path";
|
|
9
|
+
import { parseCode, serializeLilyletDoc, musicXmlEncoder, musicXmlDecoder } from "../source/lilylet/index.js";
|
|
10
|
+
const UNIT_CASES_DIR = path.join(import.meta.dirname, "assets/unit-cases");
|
|
11
|
+
const OUTPUT_DIR = path.join(import.meta.dirname, "output/musicxml-roundtrip");
|
|
12
|
+
// Known limitations - skip these tests
|
|
13
|
+
const SKIP_FILES = new Set([]);
|
|
14
|
+
// Ensure output directory exists
|
|
15
|
+
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
16
|
+
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Flatten tuplet events - extract inner events from TupletEvent
|
|
20
|
+
*/
|
|
21
|
+
const flattenEvents = (events) => {
|
|
22
|
+
const result = [];
|
|
23
|
+
for (const e of events) {
|
|
24
|
+
if (e.type === 'tuplet') {
|
|
25
|
+
result.push(...e.events);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
result.push(e);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return result;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Filter to only note and rest events (core musical content).
|
|
35
|
+
* Context events (key, time, clef, ottava, stemDirection, tempo) are handled
|
|
36
|
+
* at the measure/attribute level in MusicXML and may not survive as voice events.
|
|
37
|
+
* Other event types (tremolo, harmony, barline, markup) may also be lost or
|
|
38
|
+
* repositioned during roundtrip.
|
|
39
|
+
*/
|
|
40
|
+
const filterToNoteRest = (events) => events.filter(e => e.type === 'note' || e.type === 'rest');
|
|
41
|
+
/**
|
|
42
|
+
* Compare two LilyletDoc structures with robust comparison.
|
|
43
|
+
*
|
|
44
|
+
* Strategy:
|
|
45
|
+
* - Flatten tuplets, filter artifacts, dedupe context events
|
|
46
|
+
* - Compare by staff (not voice index) across all measures
|
|
47
|
+
* - Verify pitch/duration content of note events
|
|
48
|
+
*/
|
|
49
|
+
const compareDocuments = (doc1, doc2) => {
|
|
50
|
+
// Collect all note/rest events from all measures for a specific staff within a part
|
|
51
|
+
const collectEventsByStaff = (measures, partIndex, staff) => {
|
|
52
|
+
const allEvents = [];
|
|
53
|
+
for (const m of measures) {
|
|
54
|
+
const part = m.parts[partIndex];
|
|
55
|
+
if (part) {
|
|
56
|
+
for (const voice of part.voices) {
|
|
57
|
+
if ((voice.staff || 1) === staff) {
|
|
58
|
+
allEvents.push(...filterToNoteRest(flattenEvents(voice.events)));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return allEvents;
|
|
64
|
+
};
|
|
65
|
+
// Get all unique staves used in a part across all measures
|
|
66
|
+
const getStaves = (measures, partIndex) => {
|
|
67
|
+
const staves = new Set();
|
|
68
|
+
for (const m of measures) {
|
|
69
|
+
const part = m.parts[partIndex];
|
|
70
|
+
if (part) {
|
|
71
|
+
for (const voice of part.voices) {
|
|
72
|
+
staves.add(voice.staff || 1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return Array.from(staves).sort((a, b) => a - b);
|
|
77
|
+
};
|
|
78
|
+
// Get max parts count
|
|
79
|
+
const maxParts1 = Math.max(...doc1.measures.map(m => m.parts.length), 0);
|
|
80
|
+
const maxParts2 = Math.max(...doc2.measures.map(m => m.parts.length), 0);
|
|
81
|
+
if (maxParts1 !== maxParts2) {
|
|
82
|
+
return {
|
|
83
|
+
equal: false,
|
|
84
|
+
diff: `Part count differs: ${maxParts1} vs ${maxParts2}`
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
// Compare each part by staff
|
|
88
|
+
for (let pi = 0; pi < maxParts1; pi++) {
|
|
89
|
+
const staves1 = getStaves(doc1.measures, pi);
|
|
90
|
+
const staves2 = getStaves(doc2.measures, pi);
|
|
91
|
+
if (staves1.length !== staves2.length) {
|
|
92
|
+
return {
|
|
93
|
+
equal: false,
|
|
94
|
+
diff: `Part ${pi + 1}: Staff count differs: ${staves1.length} vs ${staves2.length}`
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// Compare events for each staff
|
|
98
|
+
for (const staff of staves1) {
|
|
99
|
+
const events1 = collectEventsByStaff(doc1.measures, pi, staff);
|
|
100
|
+
const events2 = collectEventsByStaff(doc2.measures, pi, staff);
|
|
101
|
+
if (events1.length !== events2.length) {
|
|
102
|
+
return {
|
|
103
|
+
equal: false,
|
|
104
|
+
diff: `Part ${pi + 1}, Staff ${staff}: Total event count differs: ${events1.length} vs ${events2.length}`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// Content verification: compare note/rest pitch and duration values
|
|
108
|
+
for (let i = 0; i < events1.length; i++) {
|
|
109
|
+
const e1 = events1[i];
|
|
110
|
+
const e2 = events2[i];
|
|
111
|
+
if (e1.type !== e2.type) {
|
|
112
|
+
return {
|
|
113
|
+
equal: false,
|
|
114
|
+
diff: `Part ${pi + 1}, Staff ${staff}, Event ${i + 1}: Type differs: ${e1.type} vs ${e2.type}`
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (e1.type === 'note' && e2.type === 'note') {
|
|
118
|
+
// Compare pitch count
|
|
119
|
+
if (e1.pitches.length !== e2.pitches.length) {
|
|
120
|
+
return {
|
|
121
|
+
equal: false,
|
|
122
|
+
diff: `Part ${pi + 1}, Staff ${staff}, Event ${i + 1}: Pitch count differs: ${e1.pitches.length} vs ${e2.pitches.length}`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
// Compare each pitch (phonet + octave)
|
|
126
|
+
for (let j = 0; j < e1.pitches.length; j++) {
|
|
127
|
+
const p1 = e1.pitches[j];
|
|
128
|
+
const p2 = e2.pitches[j];
|
|
129
|
+
if (p1.phonet !== p2.phonet || p1.octave !== p2.octave) {
|
|
130
|
+
return {
|
|
131
|
+
equal: false,
|
|
132
|
+
diff: `Part ${pi + 1}, Staff ${staff}, Event ${i + 1}: Pitch ${j + 1} differs: ${p1.phonet}${p1.octave} vs ${p2.phonet}${p2.octave}`
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// Compare duration (division + dots)
|
|
137
|
+
if (e1.duration.division !== e2.duration.division || e1.duration.dots !== e2.duration.dots) {
|
|
138
|
+
return {
|
|
139
|
+
equal: false,
|
|
140
|
+
diff: `Part ${pi + 1}, Staff ${staff}, Event ${i + 1}: Duration differs: div=${e1.duration.division} dots=${e1.duration.dots} vs div=${e2.duration.division} dots=${e2.duration.dots}`
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (e1.type === 'rest' && e2.type === 'rest') {
|
|
145
|
+
if (e1.duration.division !== e2.duration.division || e1.duration.dots !== e2.duration.dots) {
|
|
146
|
+
return {
|
|
147
|
+
equal: false,
|
|
148
|
+
diff: `Part ${pi + 1}, Staff ${staff}, Event ${i + 1}: Rest duration differs: div=${e1.duration.division} dots=${e1.duration.dots} vs div=${e2.duration.division} dots=${e2.duration.dots}`
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return { equal: true };
|
|
156
|
+
};
|
|
157
|
+
/**
|
|
158
|
+
* Run full roundtrip test
|
|
159
|
+
*/
|
|
160
|
+
const testRoundtrip = (filename) => {
|
|
161
|
+
const filepath = path.join(UNIT_CASES_DIR, filename);
|
|
162
|
+
try {
|
|
163
|
+
// Step 1: Read and parse original lilylet
|
|
164
|
+
const originalLyl = fs.readFileSync(filepath, "utf-8");
|
|
165
|
+
const doc1 = parseCode(originalLyl);
|
|
166
|
+
if (!doc1 || doc1.measures.length === 0) {
|
|
167
|
+
return {
|
|
168
|
+
filename,
|
|
169
|
+
status: "error",
|
|
170
|
+
error: "Failed to parse original lilylet file"
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// Step 2: Encode to MusicXML
|
|
174
|
+
const generatedXml = musicXmlEncoder.encode(doc1);
|
|
175
|
+
// Save MusicXML for inspection
|
|
176
|
+
const baseName = path.basename(filename, ".lyl");
|
|
177
|
+
fs.writeFileSync(path.join(OUTPUT_DIR, `${baseName}.musicxml`), generatedXml);
|
|
178
|
+
// Step 3: Decode MusicXML back to LilyletDoc
|
|
179
|
+
let doc2;
|
|
180
|
+
try {
|
|
181
|
+
doc2 = musicXmlDecoder.decode(generatedXml);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
return {
|
|
185
|
+
filename,
|
|
186
|
+
status: "error",
|
|
187
|
+
error: `MusicXML decode error: ${e instanceof Error ? e.message : String(e)}`,
|
|
188
|
+
originalLyl,
|
|
189
|
+
generatedXml
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (!doc2 || doc2.measures.length === 0) {
|
|
193
|
+
return {
|
|
194
|
+
filename,
|
|
195
|
+
status: "error",
|
|
196
|
+
error: "Failed to decode MusicXML output",
|
|
197
|
+
originalLyl,
|
|
198
|
+
generatedXml
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
// Step 4: Serialize back to lilylet
|
|
202
|
+
const roundtripLyl = serializeLilyletDoc(doc2);
|
|
203
|
+
fs.writeFileSync(path.join(OUTPUT_DIR, `${baseName}.roundtrip.lyl`), roundtripLyl);
|
|
204
|
+
// Step 5: Compare structures
|
|
205
|
+
const comparison = compareDocuments(doc1, doc2);
|
|
206
|
+
if (comparison.equal) {
|
|
207
|
+
return {
|
|
208
|
+
filename,
|
|
209
|
+
status: "pass",
|
|
210
|
+
originalLyl,
|
|
211
|
+
generatedXml,
|
|
212
|
+
roundtripLyl
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
return {
|
|
217
|
+
filename,
|
|
218
|
+
status: "fail",
|
|
219
|
+
error: comparison.diff,
|
|
220
|
+
originalLyl,
|
|
221
|
+
generatedXml,
|
|
222
|
+
roundtripLyl
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
return {
|
|
228
|
+
filename,
|
|
229
|
+
status: "error",
|
|
230
|
+
error: e instanceof Error ? e.message : String(e)
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* Main test runner
|
|
236
|
+
*/
|
|
237
|
+
const main = async () => {
|
|
238
|
+
console.log("MusicXML Roundtrip Test\n");
|
|
239
|
+
console.log("=".repeat(80));
|
|
240
|
+
// Get all .lyl files in unit-cases
|
|
241
|
+
const files = fs.readdirSync(UNIT_CASES_DIR)
|
|
242
|
+
.filter(f => f.endsWith(".lyl"))
|
|
243
|
+
.sort();
|
|
244
|
+
console.log(`\nFound ${files.length} test files\n`);
|
|
245
|
+
const results = [];
|
|
246
|
+
let passed = 0;
|
|
247
|
+
let failed = 0;
|
|
248
|
+
let errors = 0;
|
|
249
|
+
let skipped = 0;
|
|
250
|
+
for (const filename of files) {
|
|
251
|
+
// Check skip list
|
|
252
|
+
if (SKIP_FILES.has(filename)) {
|
|
253
|
+
results.push({ filename, status: "skip" });
|
|
254
|
+
skipped++;
|
|
255
|
+
console.log(`⏭️ ${filename} (skipped - known limitation)`);
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
// Run full roundtrip test
|
|
259
|
+
const result = testRoundtrip(filename);
|
|
260
|
+
results.push(result);
|
|
261
|
+
const statusIcon = result.status === "pass" ? "✅" :
|
|
262
|
+
result.status === "fail" ? "❌" : "⚠️";
|
|
263
|
+
console.log(`${statusIcon} ${filename}`);
|
|
264
|
+
if (result.status === "pass") {
|
|
265
|
+
passed++;
|
|
266
|
+
}
|
|
267
|
+
else if (result.status === "fail") {
|
|
268
|
+
failed++;
|
|
269
|
+
console.log(` Diff: ${result.error}`);
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
errors++;
|
|
273
|
+
console.log(` Error: ${result.error}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
console.log("\n" + "=".repeat(80));
|
|
277
|
+
console.log(`\nResults: ${passed} passed, ${failed} failed, ${errors} errors, ${skipped} skipped`);
|
|
278
|
+
console.log(`Output files saved to: ${OUTPUT_DIR}\n`);
|
|
279
|
+
// Save summary
|
|
280
|
+
const summary = {
|
|
281
|
+
total: files.length,
|
|
282
|
+
passed,
|
|
283
|
+
failed,
|
|
284
|
+
errors,
|
|
285
|
+
skipped,
|
|
286
|
+
results: results.map(r => ({
|
|
287
|
+
filename: r.filename,
|
|
288
|
+
status: r.status,
|
|
289
|
+
error: r.error
|
|
290
|
+
}))
|
|
291
|
+
};
|
|
292
|
+
fs.writeFileSync(path.join(OUTPUT_DIR, "_summary.json"), JSON.stringify(summary, null, 2));
|
|
293
|
+
// Exit with error code if any tests failed
|
|
294
|
+
process.exit(failed + errors > 0 ? 1 : 0);
|
|
295
|
+
};
|
|
296
|
+
main().catch(console.error);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MusicXML to MEI Conversion - Output to tests/output/from-xml
|
|
3
|
+
*
|
|
4
|
+
* Converts all MusicXML test files to MEI and saves to output directory.
|
|
5
|
+
*/
|
|
6
|
+
import { musicXmlDecoder, meiEncoder, serializeLilyletDoc } from '../source/lilylet/index.js';
|
|
7
|
+
import * as fs from 'fs';
|
|
8
|
+
import * as path from 'path';
|
|
9
|
+
const MUSICXML_DIR = path.join(import.meta.dirname, 'assets/musicxml');
|
|
10
|
+
const OUTPUT_DIR = path.join(import.meta.dirname, 'output/from-xml');
|
|
11
|
+
// Ensure output directory exists
|
|
12
|
+
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
13
|
+
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
function countNotes(doc) {
|
|
16
|
+
let count = 0;
|
|
17
|
+
for (const measure of doc.measures) {
|
|
18
|
+
for (const part of measure.parts) {
|
|
19
|
+
for (const voice of part.voices) {
|
|
20
|
+
for (const event of voice.events) {
|
|
21
|
+
if (event.type === 'note') {
|
|
22
|
+
count += event.pitches.length;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return count;
|
|
29
|
+
}
|
|
30
|
+
async function convertFile(filename) {
|
|
31
|
+
const filepath = path.join(MUSICXML_DIR, filename);
|
|
32
|
+
const baseName = filename.replace('.xml', '');
|
|
33
|
+
try {
|
|
34
|
+
// Read and decode MusicXML
|
|
35
|
+
const xml = fs.readFileSync(filepath, 'utf-8');
|
|
36
|
+
const doc = musicXmlDecoder.decode(xml);
|
|
37
|
+
const measures = doc.measures.length;
|
|
38
|
+
const notes = countNotes(doc);
|
|
39
|
+
// Save JSON
|
|
40
|
+
const jsonFile = path.join(OUTPUT_DIR, `${baseName}.json`);
|
|
41
|
+
fs.writeFileSync(jsonFile, JSON.stringify(doc, null, 2));
|
|
42
|
+
// Encode to MEI and save
|
|
43
|
+
const mei = meiEncoder.encode(doc);
|
|
44
|
+
const meiFile = path.join(OUTPUT_DIR, `${baseName}.mei`);
|
|
45
|
+
fs.writeFileSync(meiFile, mei);
|
|
46
|
+
// Serialize to Lilylet (.lyl) and save
|
|
47
|
+
const lyl = serializeLilyletDoc(doc);
|
|
48
|
+
const lylFile = path.join(OUTPUT_DIR, `${baseName}.lyl`);
|
|
49
|
+
fs.writeFileSync(lylFile, lyl);
|
|
50
|
+
return {
|
|
51
|
+
name: filename,
|
|
52
|
+
success: true,
|
|
53
|
+
measures,
|
|
54
|
+
notes,
|
|
55
|
+
meiFile: `${baseName}.mei`,
|
|
56
|
+
lylFile: `${baseName}.lyl`,
|
|
57
|
+
jsonFile: `${baseName}.json`,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return {
|
|
62
|
+
name: filename,
|
|
63
|
+
success: false,
|
|
64
|
+
error: error.message,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function main() {
|
|
69
|
+
console.log('MusicXML to MEI Conversion\n');
|
|
70
|
+
console.log(`Input: ${MUSICXML_DIR}`);
|
|
71
|
+
console.log(`Output: ${OUTPUT_DIR}\n`);
|
|
72
|
+
console.log('='.repeat(80));
|
|
73
|
+
const files = fs.readdirSync(MUSICXML_DIR).filter(f => f.endsWith('.xml')).sort();
|
|
74
|
+
const results = [];
|
|
75
|
+
let passed = 0;
|
|
76
|
+
let failed = 0;
|
|
77
|
+
for (const file of files) {
|
|
78
|
+
const result = await convertFile(file);
|
|
79
|
+
results.push(result);
|
|
80
|
+
const status = result.success ? '✅' : '❌';
|
|
81
|
+
if (result.success) {
|
|
82
|
+
console.log(`${status} ${file}`);
|
|
83
|
+
console.log(` → ${result.meiFile}, ${result.lylFile} (${result.measures} measures, ${result.notes} notes)`);
|
|
84
|
+
passed++;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
console.log(`${status} ${file}`);
|
|
88
|
+
console.log(` Error: ${result.error}`);
|
|
89
|
+
failed++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
console.log('\n' + '='.repeat(80));
|
|
93
|
+
console.log(`\nConversion complete: ${passed} succeeded, ${failed} failed`);
|
|
94
|
+
console.log(`Output files in: ${OUTPUT_DIR}`);
|
|
95
|
+
// Write summary JSON
|
|
96
|
+
const summaryFile = path.join(OUTPUT_DIR, '_summary.json');
|
|
97
|
+
fs.writeFileSync(summaryFile, JSON.stringify({
|
|
98
|
+
timestamp: new Date().toISOString(),
|
|
99
|
+
inputDir: MUSICXML_DIR,
|
|
100
|
+
outputDir: OUTPUT_DIR,
|
|
101
|
+
total: files.length,
|
|
102
|
+
passed,
|
|
103
|
+
failed,
|
|
104
|
+
results,
|
|
105
|
+
}, null, 2));
|
|
106
|
+
console.log(`Summary: ${summaryFile}`);
|
|
107
|
+
// List output files
|
|
108
|
+
console.log('\nGenerated files:');
|
|
109
|
+
const outputFiles = fs.readdirSync(OUTPUT_DIR).sort();
|
|
110
|
+
for (const f of outputFiles) {
|
|
111
|
+
const stat = fs.statSync(path.join(OUTPUT_DIR, f));
|
|
112
|
+
console.log(` ${f} (${(stat.size / 1024).toFixed(1)} KB)`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
main();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import * as lilylet from "../source/lilylet.js";
|
|
3
|
+
const parse = (lyl_dir) => {
|
|
4
|
+
const files = fs.readdirSync(lyl_dir);
|
|
5
|
+
for (const file of files) {
|
|
6
|
+
const filePath = `${lyl_dir}/${file}`;
|
|
7
|
+
const stat = fs.statSync(filePath);
|
|
8
|
+
if (stat.isDirectory())
|
|
9
|
+
continue;
|
|
10
|
+
if (!file.endsWith('.lyl'))
|
|
11
|
+
continue;
|
|
12
|
+
const code = fs.readFileSync(filePath, { encoding: "utf-8" });
|
|
13
|
+
lilylet.parseCode(code);
|
|
14
|
+
console.log(file, "parsing passed.");
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
parse("./tests/assets/unit-cases");
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
// @ts-ignore
|
|
3
|
+
import createVerovioModule from "verovio/wasm";
|
|
4
|
+
// @ts-ignore
|
|
5
|
+
import { VerovioToolkit } from "verovio/esm";
|
|
6
|
+
import * as lilylet from "../source/lilylet.js";
|
|
7
|
+
const code = fs.readFileSync("/home/camus/work/openmusictheory-lilylet/Graphics/lilylet/k283.lyl", "utf-8");
|
|
8
|
+
const doc = lilylet.parseCode(code);
|
|
9
|
+
const mei = lilylet.meiEncoder.encode(doc);
|
|
10
|
+
// Save MEI for debugging
|
|
11
|
+
fs.writeFileSync("/home/camus/work/openmusictheory-lilylet/Graphics/lilylet/k283.mei", mei);
|
|
12
|
+
const measureCount = doc.measures?.length || 1;
|
|
13
|
+
let staffCount = 1;
|
|
14
|
+
if (doc.measures.length > 0) {
|
|
15
|
+
const firstMeasure = doc.measures[0];
|
|
16
|
+
staffCount = firstMeasure.parts.reduce((total, part) => {
|
|
17
|
+
const maxStaff = part.voices.reduce((max, voice) => Math.max(max, voice.staff || 1), 1);
|
|
18
|
+
return total + maxStaff;
|
|
19
|
+
}, 0) || 1;
|
|
20
|
+
}
|
|
21
|
+
const pageHeight = Math.max(2000, Math.ceil(measureCount / 20) * 2000) * 2 * staffCount;
|
|
22
|
+
console.log(`Measures: ${measureCount}, Staves: ${staffCount}, PageHeight: ${pageHeight}`);
|
|
23
|
+
const VerovioModule = await createVerovioModule();
|
|
24
|
+
const vrvToolkit = new VerovioToolkit(VerovioModule);
|
|
25
|
+
vrvToolkit.setOptions({ scale: 40, adjustPageHeight: true, pageHeight, pageWidth: 2100 });
|
|
26
|
+
const success = vrvToolkit.loadData(mei);
|
|
27
|
+
if (success === false) {
|
|
28
|
+
console.error("Failed:", vrvToolkit.getLog());
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const svg = vrvToolkit.renderToSVG(1);
|
|
32
|
+
fs.writeFileSync("/home/camus/work/openmusictheory-lilylet/Graphics/lilylet/k283.svg", svg);
|
|
33
|
+
console.log(`Rendered: ${svg.length} bytes → k283.svg`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
// @ts-ignore
|
|
3
|
+
import createVerovioModule from "verovio/wasm";
|
|
4
|
+
// @ts-ignore
|
|
5
|
+
import { VerovioToolkit } from "verovio/esm";
|
|
6
|
+
import * as lilylet from "../source/lilylet.js";
|
|
7
|
+
const lylPath = process.argv[2];
|
|
8
|
+
if (!lylPath) {
|
|
9
|
+
console.error("Usage: tsx tests/render-lyl.ts <file.lyl> [output.svg]");
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const svgPath = process.argv[3] || lylPath.replace(/\.lyl$/, ".svg");
|
|
13
|
+
const code = fs.readFileSync(lylPath, "utf-8");
|
|
14
|
+
const doc = lilylet.parseCode(code);
|
|
15
|
+
const mei = lilylet.meiEncoder.encode(doc);
|
|
16
|
+
const measureCount = doc.measures?.length || 1;
|
|
17
|
+
let staffCount = 1;
|
|
18
|
+
if (doc.measures.length > 0) {
|
|
19
|
+
const fm = doc.measures[0];
|
|
20
|
+
staffCount = fm.parts.reduce((t, p) => {
|
|
21
|
+
const ms = p.voices.reduce((m, v) => Math.max(m, v.staff || 1), 1);
|
|
22
|
+
return t + ms;
|
|
23
|
+
}, 0) || 1;
|
|
24
|
+
}
|
|
25
|
+
const pageHeight = Math.max(2000, Math.ceil(measureCount / 20) * 2000) * 2 * staffCount;
|
|
26
|
+
const VM = await createVerovioModule();
|
|
27
|
+
const vt = new VerovioToolkit(VM);
|
|
28
|
+
vt.setOptions({ scale: 40, adjustPageHeight: true, pageHeight, pageWidth: 2100 });
|
|
29
|
+
if (vt.loadData(mei) === false) {
|
|
30
|
+
console.error("Verovio failed:", vt.getLog());
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
const svg = vt.renderToSVG(1);
|
|
34
|
+
fs.writeFileSync(svgPath, svg);
|
|
35
|
+
console.log("Rendered:", svgPath);
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
export {};
|