@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,558 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LilyPond Roundtrip Test
|
|
3
|
-
*
|
|
4
|
-
* Tests the lilylet -> lilypond -> 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, lilypondEncoder } from "../source/lilylet/index.js";
|
|
10
|
-
// Import the LilyPond decoder
|
|
11
|
-
import { decode as decodeLilypond } from "../source/lilylet/lilypondDecoder.js";
|
|
12
|
-
const UNIT_CASES_DIR = path.join(import.meta.dirname, "assets/unit-cases");
|
|
13
|
-
const OUTPUT_DIR = path.join(import.meta.dirname, "output/lilypond-roundtrip");
|
|
14
|
-
// Ensure output directory exists
|
|
15
|
-
if (!fs.existsSync(OUTPUT_DIR)) {
|
|
16
|
-
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Normalize lilylet content for comparison
|
|
20
|
-
* - Remove comments
|
|
21
|
-
* - Normalize whitespace
|
|
22
|
-
* - Remove trailing measure markers
|
|
23
|
-
*/
|
|
24
|
-
const normalizeLyl = (content) => {
|
|
25
|
-
return content
|
|
26
|
-
// Remove comments
|
|
27
|
-
.replace(/%[^\n]*/g, '')
|
|
28
|
-
// Remove measure markers like "| %1"
|
|
29
|
-
.replace(/\|\s*%\d+/g, '|')
|
|
30
|
-
// Normalize whitespace
|
|
31
|
-
.replace(/\s+/g, ' ')
|
|
32
|
-
// Remove leading/trailing whitespace
|
|
33
|
-
.trim();
|
|
34
|
-
};
|
|
35
|
-
/**
|
|
36
|
-
* Compare two LilyletDoc structures
|
|
37
|
-
*
|
|
38
|
-
* Note: Measure boundaries may differ between lilylet parser (doesn't enforce time signatures)
|
|
39
|
-
* and lotus parser (enforces time signatures). We compare total events across all measures.
|
|
40
|
-
*/
|
|
41
|
-
const compareDocuments = (doc1, doc2) => {
|
|
42
|
-
// Filter helper - remove parser artifacts that don't represent musical content
|
|
43
|
-
const filterEvents = (events) => events.filter(e => {
|
|
44
|
-
if (e.type === 'pitchReset')
|
|
45
|
-
return false;
|
|
46
|
-
if (e.type === 'context' && 'staff' in e)
|
|
47
|
-
return false;
|
|
48
|
-
if (e.type === 'context' && 'stemDirection' in e)
|
|
49
|
-
return false;
|
|
50
|
-
if (e.type === 'rest' && e.invisible)
|
|
51
|
-
return false;
|
|
52
|
-
return true;
|
|
53
|
-
});
|
|
54
|
-
// Remove redundant consecutive context events
|
|
55
|
-
const dedupeContextEvents = (events) => {
|
|
56
|
-
const result = [];
|
|
57
|
-
let lastClef;
|
|
58
|
-
let lastKey;
|
|
59
|
-
let lastTime;
|
|
60
|
-
for (const e of events) {
|
|
61
|
-
if (e.type === 'context') {
|
|
62
|
-
const ctx = e;
|
|
63
|
-
if ('stemDirection' in ctx)
|
|
64
|
-
continue; // already filtered
|
|
65
|
-
if ('clef' in ctx) {
|
|
66
|
-
if (ctx.clef === lastClef)
|
|
67
|
-
continue;
|
|
68
|
-
lastClef = ctx.clef;
|
|
69
|
-
}
|
|
70
|
-
if ('key' in ctx) {
|
|
71
|
-
const keyStr = JSON.stringify(ctx.key);
|
|
72
|
-
if (keyStr === lastKey)
|
|
73
|
-
continue;
|
|
74
|
-
lastKey = keyStr;
|
|
75
|
-
}
|
|
76
|
-
if ('time' in ctx && ctx.time) {
|
|
77
|
-
const timeStr = `${ctx.time.numerator}/${ctx.time.denominator}`;
|
|
78
|
-
if (timeStr === lastTime)
|
|
79
|
-
continue;
|
|
80
|
-
lastTime = timeStr;
|
|
81
|
-
}
|
|
82
|
-
if ('timeSig' in ctx && ctx.timeSig) {
|
|
83
|
-
const timeSigStr = `${ctx.timeSig.numerator}/${ctx.timeSig.denominator}`;
|
|
84
|
-
if (timeSigStr === lastTime)
|
|
85
|
-
continue;
|
|
86
|
-
lastTime = timeSigStr;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
result.push(e);
|
|
90
|
-
}
|
|
91
|
-
return result;
|
|
92
|
-
};
|
|
93
|
-
// Collect musically-significant events (notes, rests, tuplets, times) preserving structure
|
|
94
|
-
const collectMusical = (events) => events.filter(e => e.type === 'note' || e.type === 'rest' || e.type === 'tuplet' || e.type === 'times');
|
|
95
|
-
// Flatten note/rest events from potentially nested tuplets/times (for pitch/duration checks)
|
|
96
|
-
const flattenNoteRests = (events) => {
|
|
97
|
-
const result = [];
|
|
98
|
-
for (const e of events) {
|
|
99
|
-
if (e.type === 'note' || e.type === 'rest') {
|
|
100
|
-
result.push(e);
|
|
101
|
-
}
|
|
102
|
-
else if (e.type === 'tuplet' || e.type === 'times') {
|
|
103
|
-
result.push(...flattenNoteRests(e.events));
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
return result;
|
|
107
|
-
};
|
|
108
|
-
// Format a note/rest event for diff display
|
|
109
|
-
const describeEvent = (e, index) => {
|
|
110
|
-
if (e.type === 'rest') {
|
|
111
|
-
const r = e;
|
|
112
|
-
if (r.pitch)
|
|
113
|
-
return `[${index}] rest(${r.pitch.phonet}${r.pitch.octave}) dur=${r.duration.division}${'.'.repeat(r.duration.dots)}`;
|
|
114
|
-
return `[${index}] rest dur=${r.duration.division}${'.'.repeat(r.duration.dots)}`;
|
|
115
|
-
}
|
|
116
|
-
const n = e;
|
|
117
|
-
const pitches = n.pitches.map(p => `${p.phonet}${p.accidental ? '(' + p.accidental + ')' : ''}${p.octave}`).join('+');
|
|
118
|
-
return `[${index}] note(${pitches}) dur=${n.duration.division}${'.'.repeat(n.duration.dots)}`;
|
|
119
|
-
};
|
|
120
|
-
// Compare two note/rest events
|
|
121
|
-
const eventsMatch = (a, b) => {
|
|
122
|
-
if (a.type !== b.type)
|
|
123
|
-
return false;
|
|
124
|
-
// Compare duration
|
|
125
|
-
if (a.duration.division !== b.duration.division)
|
|
126
|
-
return false;
|
|
127
|
-
if (a.duration.dots !== b.duration.dots)
|
|
128
|
-
return false;
|
|
129
|
-
if (a.type === 'note' && b.type === 'note') {
|
|
130
|
-
const na = a;
|
|
131
|
-
const nb = b;
|
|
132
|
-
if (na.pitches.length !== nb.pitches.length)
|
|
133
|
-
return false;
|
|
134
|
-
for (let i = 0; i < na.pitches.length; i++) {
|
|
135
|
-
if (na.pitches[i].phonet !== nb.pitches[i].phonet)
|
|
136
|
-
return false;
|
|
137
|
-
if (na.pitches[i].octave !== nb.pitches[i].octave)
|
|
138
|
-
return false;
|
|
139
|
-
// Don't compare accidental - it may differ between parsers due to key context
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
if (a.type === 'rest' && b.type === 'rest') {
|
|
143
|
-
const ra = a;
|
|
144
|
-
const rb = b;
|
|
145
|
-
// Both pitched or both unpitched
|
|
146
|
-
if (!!ra.pitch !== !!rb.pitch)
|
|
147
|
-
return false;
|
|
148
|
-
if (ra.pitch && rb.pitch) {
|
|
149
|
-
if (ra.pitch.phonet !== rb.pitch.phonet)
|
|
150
|
-
return false;
|
|
151
|
-
if (ra.pitch.octave !== rb.pitch.octave)
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
return true;
|
|
156
|
-
};
|
|
157
|
-
// Collect events for a specific voice (by 0-based index within a staff) across all measures.
|
|
158
|
-
// Per-voice comparison is robust to measure-boundary drift caused by overfull source measures:
|
|
159
|
-
// if voice A's last note overflows into the next measure in the decoder, the comparison
|
|
160
|
-
// still sees voice A's events as a unit rather than interleaved with voice B's events.
|
|
161
|
-
const collectEventsByVoiceIndex = (measures, partIndex, staff, voiceIndex) => {
|
|
162
|
-
const allEvents = [];
|
|
163
|
-
for (const m of measures) {
|
|
164
|
-
const part = m.parts[partIndex];
|
|
165
|
-
if (!part)
|
|
166
|
-
continue;
|
|
167
|
-
let idx = 0;
|
|
168
|
-
for (const voice of part.voices) {
|
|
169
|
-
if (voice.staff !== staff)
|
|
170
|
-
continue;
|
|
171
|
-
if (idx === voiceIndex) {
|
|
172
|
-
allEvents.push(...filterEvents(voice.events));
|
|
173
|
-
break;
|
|
174
|
-
}
|
|
175
|
-
idx++;
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return dedupeContextEvents(allEvents);
|
|
179
|
-
};
|
|
180
|
-
// Count voices per staff across all measures (only voices with non-spacer musical content)
|
|
181
|
-
const getVoiceCountByStaff = (measures, partIndex) => {
|
|
182
|
-
const maxVoices = new Map();
|
|
183
|
-
for (const m of measures) {
|
|
184
|
-
const part = m.parts[partIndex];
|
|
185
|
-
if (!part)
|
|
186
|
-
continue;
|
|
187
|
-
const staffCounts = new Map();
|
|
188
|
-
for (const voice of part.voices) {
|
|
189
|
-
if (!hasNonSpacerContent(voice.events))
|
|
190
|
-
continue;
|
|
191
|
-
const s = voice.staff || 1;
|
|
192
|
-
staffCounts.set(s, (staffCounts.get(s) || 0) + 1);
|
|
193
|
-
}
|
|
194
|
-
for (const [s, count] of staffCounts) {
|
|
195
|
-
maxVoices.set(s, Math.max(maxVoices.get(s) || 0, count));
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
return maxVoices;
|
|
199
|
-
};
|
|
200
|
-
// Check if a voice has any non-spacer musical content
|
|
201
|
-
const hasNonSpacerContent = (events) => events.some(e => {
|
|
202
|
-
if (e.type === 'note')
|
|
203
|
-
return true;
|
|
204
|
-
if (e.type === 'rest' && !e.invisible)
|
|
205
|
-
return true;
|
|
206
|
-
if (e.type === 'tuplet' || e.type === 'times' || e.type === 'tremolo')
|
|
207
|
-
return true;
|
|
208
|
-
return false;
|
|
209
|
-
});
|
|
210
|
-
// Get all unique staves used in a part that have at least some real (non-spacer) content.
|
|
211
|
-
// Spacer-only staves are filtered because the LilyPond encoder/decoder drops them.
|
|
212
|
-
const getStaves = (measures, partIndex) => {
|
|
213
|
-
// Collect staff → whether any measure has non-spacer content
|
|
214
|
-
const staffHasContent = new Map();
|
|
215
|
-
for (const m of measures) {
|
|
216
|
-
const part = m.parts[partIndex];
|
|
217
|
-
if (part) {
|
|
218
|
-
for (const voice of part.voices) {
|
|
219
|
-
const s = voice.staff || 1;
|
|
220
|
-
if (!staffHasContent.get(s) && hasNonSpacerContent(voice.events)) {
|
|
221
|
-
staffHasContent.set(s, true);
|
|
222
|
-
}
|
|
223
|
-
else if (!staffHasContent.has(s)) {
|
|
224
|
-
staffHasContent.set(s, false);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
// Only return staves that have real content in at least one measure
|
|
230
|
-
return Array.from(staffHasContent.entries())
|
|
231
|
-
.filter(([, hasContent]) => hasContent)
|
|
232
|
-
.map(([s]) => s)
|
|
233
|
-
.sort((a, b) => a - b);
|
|
234
|
-
};
|
|
235
|
-
// Get max parts count
|
|
236
|
-
const maxParts1 = Math.max(...doc1.measures.map(m => m.parts.length), 0);
|
|
237
|
-
const maxParts2 = Math.max(...doc2.measures.map(m => m.parts.length), 0);
|
|
238
|
-
if (maxParts1 !== maxParts2) {
|
|
239
|
-
return {
|
|
240
|
-
equal: false,
|
|
241
|
-
diff: `Part count differs: ${maxParts1} vs ${maxParts2}`
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
// Compare each part
|
|
245
|
-
for (let pi = 0; pi < maxParts1; pi++) {
|
|
246
|
-
const staves1 = getStaves(doc1.measures, pi);
|
|
247
|
-
const staves2 = getStaves(doc2.measures, pi);
|
|
248
|
-
// Compare staff counts
|
|
249
|
-
if (staves1.length !== staves2.length) {
|
|
250
|
-
return {
|
|
251
|
-
equal: false,
|
|
252
|
-
diff: `Part ${pi + 1}: Staff count differs: ${staves1.length} (staves ${staves1.join(',')}) vs ${staves2.length} (staves ${staves2.join(',')})`
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
// Compare voice counts per staff
|
|
256
|
-
const voices1 = getVoiceCountByStaff(doc1.measures, pi);
|
|
257
|
-
const voices2 = getVoiceCountByStaff(doc2.measures, pi);
|
|
258
|
-
for (const staff of staves1) {
|
|
259
|
-
const v1 = voices1.get(staff) || 0;
|
|
260
|
-
const v2 = voices2.get(staff) || 0;
|
|
261
|
-
if (v1 !== v2) {
|
|
262
|
-
return {
|
|
263
|
-
equal: false,
|
|
264
|
-
diff: `Part ${pi + 1}, Staff ${staff}: Voice count differs: ${v1} vs ${v2}`
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
// Compare events for each staff, voice by voice
|
|
269
|
-
for (const staff of staves1) {
|
|
270
|
-
const numVoices = voices1.get(staff) || 0;
|
|
271
|
-
for (let vi = 0; vi < numVoices; vi++) {
|
|
272
|
-
const events1 = collectEventsByVoiceIndex(doc1.measures, pi, staff, vi);
|
|
273
|
-
const events2 = collectEventsByVoiceIndex(doc2.measures, pi, staff, vi);
|
|
274
|
-
// Compare event count
|
|
275
|
-
const noteRests1 = flattenNoteRests(events1);
|
|
276
|
-
const noteRests2 = flattenNoteRests(events2);
|
|
277
|
-
if (noteRests1.length !== noteRests2.length) {
|
|
278
|
-
return {
|
|
279
|
-
equal: false,
|
|
280
|
-
diff: `Part ${pi + 1}, Staff ${staff}, Voice ${vi + 1}: Note/rest count differs: ${noteRests1.length} vs ${noteRests2.length}`
|
|
281
|
-
};
|
|
282
|
-
}
|
|
283
|
-
// Compare each note/rest event content
|
|
284
|
-
for (let i = 0; i < noteRests1.length; i++) {
|
|
285
|
-
if (!eventsMatch(noteRests1[i], noteRests2[i])) {
|
|
286
|
-
return {
|
|
287
|
-
equal: false,
|
|
288
|
-
diff: `Part ${pi + 1}, Staff ${staff}, Voice ${vi + 1}: Event mismatch at index ${i}: ${describeEvent(noteRests1[i], i)} vs ${describeEvent(noteRests2[i], i)}`
|
|
289
|
-
};
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
// Compare tuplet/times structure: type, count, and ratios must match
|
|
293
|
-
const tuplesLike1 = collectMusical(events1).filter(e => e.type === 'tuplet' || e.type === 'times');
|
|
294
|
-
const tuplesLike2 = collectMusical(events2).filter(e => e.type === 'tuplet' || e.type === 'times');
|
|
295
|
-
if (tuplesLike1.length !== tuplesLike2.length) {
|
|
296
|
-
return {
|
|
297
|
-
equal: false,
|
|
298
|
-
diff: `Part ${pi + 1}, Staff ${staff}, Voice ${vi + 1}: Tuplet/times count differs: ${tuplesLike1.length} vs ${tuplesLike2.length}`
|
|
299
|
-
};
|
|
300
|
-
}
|
|
301
|
-
for (let i = 0; i < tuplesLike1.length; i++) {
|
|
302
|
-
const t1 = tuplesLike1[i], t2 = tuplesLike2[i];
|
|
303
|
-
if (t1.type !== t2.type) {
|
|
304
|
-
return {
|
|
305
|
-
equal: false,
|
|
306
|
-
diff: `Part ${pi + 1}, Staff ${staff}, Voice ${vi + 1}: Tuplet ${i} type differs: "${t1.type}" vs "${t2.type}"`
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
const r1 = t1.ratio, r2 = t2.ratio;
|
|
310
|
-
if (r1.numerator !== r2.numerator || r1.denominator !== r2.denominator) {
|
|
311
|
-
return {
|
|
312
|
-
equal: false,
|
|
313
|
-
diff: `Part ${pi + 1}, Staff ${staff}, Voice ${vi + 1}: Tuplet ${i} ratio differs: ${r1.numerator}/${r1.denominator} vs ${r2.numerator}/${r2.denominator}`
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
} // end voice loop
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
return { equal: true };
|
|
321
|
-
};
|
|
322
|
-
/**
|
|
323
|
-
* Run roundtrip test on a single file
|
|
324
|
-
*/
|
|
325
|
-
const testFile = async (filename) => {
|
|
326
|
-
const filepath = path.join(UNIT_CASES_DIR, filename);
|
|
327
|
-
try {
|
|
328
|
-
// Step 1: Read and parse original lilylet
|
|
329
|
-
const originalLyl = fs.readFileSync(filepath, "utf-8");
|
|
330
|
-
const doc1 = parseCode(originalLyl);
|
|
331
|
-
if (!doc1 || doc1.measures.length === 0) {
|
|
332
|
-
return {
|
|
333
|
-
filename,
|
|
334
|
-
status: "error",
|
|
335
|
-
error: "Failed to parse original lilylet file"
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
// Step 2: Encode to LilyPond
|
|
339
|
-
const generatedLy = lilypondEncoder.encode(doc1, {
|
|
340
|
-
paper: { width: 210, height: 297 },
|
|
341
|
-
fontSize: 20,
|
|
342
|
-
withMIDI: false,
|
|
343
|
-
autoBeaming: false
|
|
344
|
-
});
|
|
345
|
-
// Step 3: Decode LilyPond back to LilyletDoc
|
|
346
|
-
let doc2;
|
|
347
|
-
try {
|
|
348
|
-
doc2 = await decodeLilypond(generatedLy);
|
|
349
|
-
}
|
|
350
|
-
catch (e) {
|
|
351
|
-
// LilyPond decoder might not be fully compatible
|
|
352
|
-
// Fall back to comparing serialized output
|
|
353
|
-
return {
|
|
354
|
-
filename,
|
|
355
|
-
status: "error",
|
|
356
|
-
error: `LilyPond decode error: ${e instanceof Error ? e.message : String(e)}`,
|
|
357
|
-
originalLyl,
|
|
358
|
-
generatedLy
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
if (!doc2 || doc2.measures.length === 0) {
|
|
362
|
-
return {
|
|
363
|
-
filename,
|
|
364
|
-
status: "error",
|
|
365
|
-
error: "Failed to decode LilyPond output",
|
|
366
|
-
originalLyl,
|
|
367
|
-
generatedLy
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
// Step 4: Serialize back to lilylet
|
|
371
|
-
const roundtripLyl = serializeLilyletDoc(doc2);
|
|
372
|
-
// Step 5: Save .ly and .json for inspection
|
|
373
|
-
const baseName = path.basename(filename, ".lyl");
|
|
374
|
-
fs.writeFileSync(path.join(OUTPUT_DIR, `${baseName}.ly`), generatedLy);
|
|
375
|
-
fs.writeFileSync(path.join(OUTPUT_DIR, `${baseName}.json`), JSON.stringify(doc1, null, 2));
|
|
376
|
-
// Step 6: Compare structures
|
|
377
|
-
const comparison = compareDocuments(doc1, doc2);
|
|
378
|
-
if (comparison.equal) {
|
|
379
|
-
return {
|
|
380
|
-
filename,
|
|
381
|
-
status: "pass",
|
|
382
|
-
originalLyl,
|
|
383
|
-
generatedLy,
|
|
384
|
-
roundtripLyl
|
|
385
|
-
};
|
|
386
|
-
}
|
|
387
|
-
else {
|
|
388
|
-
return {
|
|
389
|
-
filename,
|
|
390
|
-
status: "fail",
|
|
391
|
-
error: comparison.diff,
|
|
392
|
-
originalLyl,
|
|
393
|
-
generatedLy,
|
|
394
|
-
roundtripLyl
|
|
395
|
-
};
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
catch (e) {
|
|
399
|
-
return {
|
|
400
|
-
filename,
|
|
401
|
-
status: "error",
|
|
402
|
-
error: e instanceof Error ? e.message : String(e)
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
};
|
|
406
|
-
/**
|
|
407
|
-
* Run encoding test with full content verification
|
|
408
|
-
* Saves both .ly and .json for inspection
|
|
409
|
-
*/
|
|
410
|
-
const testEncoding = (filename) => {
|
|
411
|
-
const filepath = path.join(UNIT_CASES_DIR, filename);
|
|
412
|
-
const baseName = path.basename(filename, ".lyl");
|
|
413
|
-
try {
|
|
414
|
-
// Step 1: Read and parse original lilylet
|
|
415
|
-
const originalLyl = fs.readFileSync(filepath, "utf-8");
|
|
416
|
-
const doc = parseCode(originalLyl);
|
|
417
|
-
if (!doc || doc.measures.length === 0) {
|
|
418
|
-
return {
|
|
419
|
-
filename,
|
|
420
|
-
status: "error",
|
|
421
|
-
error: "Failed to parse original lilylet file"
|
|
422
|
-
};
|
|
423
|
-
}
|
|
424
|
-
// Step 2: Save LilyletDoc as JSON for inspection
|
|
425
|
-
fs.writeFileSync(path.join(OUTPUT_DIR, `${baseName}.json`), JSON.stringify(doc, null, 2));
|
|
426
|
-
// Step 3: Encode to LilyPond
|
|
427
|
-
const generatedLy = lilypondEncoder.encode(doc, {
|
|
428
|
-
paper: { width: 210, height: 297 },
|
|
429
|
-
fontSize: 20,
|
|
430
|
-
withMIDI: false,
|
|
431
|
-
autoBeaming: false
|
|
432
|
-
});
|
|
433
|
-
// Step 4: Check that output is valid LilyPond syntax (basic checks)
|
|
434
|
-
if (!generatedLy.includes('\\version')) {
|
|
435
|
-
return {
|
|
436
|
-
filename,
|
|
437
|
-
status: "error",
|
|
438
|
-
error: "Generated LilyPond missing version header"
|
|
439
|
-
};
|
|
440
|
-
}
|
|
441
|
-
if (!generatedLy.includes('\\score')) {
|
|
442
|
-
return {
|
|
443
|
-
filename,
|
|
444
|
-
status: "error",
|
|
445
|
-
error: "Generated LilyPond missing score block"
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
// Step 5: Save LilyPond output for inspection
|
|
449
|
-
fs.writeFileSync(path.join(OUTPUT_DIR, `${baseName}.ly`), generatedLy);
|
|
450
|
-
// Step 6: Verify content - check pitch encoding and spacer rests
|
|
451
|
-
// Each measure should have pitches with correct octave values
|
|
452
|
-
let currentTimeSig;
|
|
453
|
-
for (let mi = 0; mi < doc.measures.length; mi++) {
|
|
454
|
-
const measure = doc.measures[mi];
|
|
455
|
-
if (measure.timeSig)
|
|
456
|
-
currentTimeSig = measure.timeSig;
|
|
457
|
-
for (const part of measure.parts) {
|
|
458
|
-
for (const voice of part.voices) {
|
|
459
|
-
for (const event of voice.events) {
|
|
460
|
-
if (event.type === 'note') {
|
|
461
|
-
const noteEvent = event;
|
|
462
|
-
for (const pitch of noteEvent.pitches) {
|
|
463
|
-
// Verify octave is a valid number (after resolution)
|
|
464
|
-
if (typeof pitch.octave !== 'number' || isNaN(pitch.octave)) {
|
|
465
|
-
return {
|
|
466
|
-
filename,
|
|
467
|
-
status: "fail",
|
|
468
|
-
error: `Measure ${mi + 1}: Invalid octave value for pitch ${pitch.phonet}`
|
|
469
|
-
};
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
// Step 7: Verify spacer rests match time signature
|
|
478
|
-
if (currentTimeSig) {
|
|
479
|
-
const { numerator, denominator } = currentTimeSig;
|
|
480
|
-
const quarterBeats = numerator * (4 / denominator);
|
|
481
|
-
// Check for incorrect s1 in non-4/4 time
|
|
482
|
-
if (quarterBeats !== 4 && generatedLy.includes('{ s1 }')) {
|
|
483
|
-
return {
|
|
484
|
-
filename,
|
|
485
|
-
status: "fail",
|
|
486
|
-
error: `Spacer rest 's1' used in ${numerator}/${denominator} time (should be duration matching ${quarterBeats} quarter beats)`
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
return {
|
|
491
|
-
filename,
|
|
492
|
-
status: "pass",
|
|
493
|
-
originalLyl,
|
|
494
|
-
generatedLy
|
|
495
|
-
};
|
|
496
|
-
}
|
|
497
|
-
catch (e) {
|
|
498
|
-
return {
|
|
499
|
-
filename,
|
|
500
|
-
status: "error",
|
|
501
|
-
error: e instanceof Error ? e.message : String(e)
|
|
502
|
-
};
|
|
503
|
-
}
|
|
504
|
-
};
|
|
505
|
-
/**
|
|
506
|
-
* Main test runner
|
|
507
|
-
*/
|
|
508
|
-
const main = async () => {
|
|
509
|
-
console.log("LilyPond Encoder Test\n");
|
|
510
|
-
console.log("=".repeat(80));
|
|
511
|
-
// Get all .lyl files in unit-cases
|
|
512
|
-
const files = fs.readdirSync(UNIT_CASES_DIR)
|
|
513
|
-
.filter(f => f.endsWith(".lyl"))
|
|
514
|
-
.sort();
|
|
515
|
-
console.log(`\nFound ${files.length} test files\n`);
|
|
516
|
-
const results = [];
|
|
517
|
-
let passed = 0;
|
|
518
|
-
let failed = 0;
|
|
519
|
-
let errors = 0;
|
|
520
|
-
for (const filename of files) {
|
|
521
|
-
// Run full roundtrip test
|
|
522
|
-
const result = await testFile(filename);
|
|
523
|
-
results.push(result);
|
|
524
|
-
const statusIcon = result.status === "pass" ? "✅" :
|
|
525
|
-
result.status === "fail" ? "❌" : "⚠️";
|
|
526
|
-
console.log(`${statusIcon} ${filename}`);
|
|
527
|
-
if (result.status === "pass") {
|
|
528
|
-
passed++;
|
|
529
|
-
}
|
|
530
|
-
else if (result.status === "fail") {
|
|
531
|
-
failed++;
|
|
532
|
-
console.log(` Diff: ${result.error}`);
|
|
533
|
-
}
|
|
534
|
-
else {
|
|
535
|
-
errors++;
|
|
536
|
-
console.log(` Error: ${result.error}`);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
console.log("\n" + "=".repeat(80));
|
|
540
|
-
console.log(`\nResults: ${passed} passed, ${failed} failed, ${errors} errors`);
|
|
541
|
-
console.log(`Output files saved to: ${OUTPUT_DIR}\n`);
|
|
542
|
-
// Save summary
|
|
543
|
-
const summary = {
|
|
544
|
-
total: files.length,
|
|
545
|
-
passed,
|
|
546
|
-
failed,
|
|
547
|
-
errors,
|
|
548
|
-
results: results.map(r => ({
|
|
549
|
-
filename: r.filename,
|
|
550
|
-
status: r.status,
|
|
551
|
-
error: r.error
|
|
552
|
-
}))
|
|
553
|
-
};
|
|
554
|
-
fs.writeFileSync(path.join(OUTPUT_DIR, "_summary.json"), JSON.stringify(summary, null, 2));
|
|
555
|
-
// Exit with error code if any tests failed
|
|
556
|
-
process.exit(failed + errors > 0 ? 1 : 0);
|
|
557
|
-
};
|
|
558
|
-
main().catch(console.error);
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test for LilyPond decoder using MutopiaProject files
|
|
3
|
-
*
|
|
4
|
-
* Usage: npx ts-node tests/lilypondDecoder.ts <path-to-ly-files> [output-dir] [max-files]
|
|
5
|
-
*/
|
|
6
|
-
import * as fs from 'fs';
|
|
7
|
-
import * as path from 'path';
|
|
8
|
-
import { decode } from "../source/lilylet/lilypondDecoder.js";
|
|
9
|
-
import { serializeLilyletDoc } from "../source/lilylet/serializer.js";
|
|
10
|
-
// Get path from command line argument
|
|
11
|
-
const args = process.argv.slice(2);
|
|
12
|
-
const LY_FILES_PATH = args[0];
|
|
13
|
-
const OUTPUT_DIR = args[1] || './tests/output/from-ly';
|
|
14
|
-
const MAX_FILES = parseInt(args[2] || '100', 10);
|
|
15
|
-
if (!LY_FILES_PATH) {
|
|
16
|
-
console.error('Usage: npx ts-node tests/lilypondDecoder.ts <path-to-ly-files> [output-dir] [max-files]');
|
|
17
|
-
console.error('Example: npx ts-node tests/lilypondDecoder.ts ~/work/others/MutopiaProject ./output 50');
|
|
18
|
-
process.exit(1);
|
|
19
|
-
}
|
|
20
|
-
// Find .ly files recursively
|
|
21
|
-
const findLyFiles = (dir, maxFiles = 100) => {
|
|
22
|
-
const files = [];
|
|
23
|
-
const walk = (currentDir) => {
|
|
24
|
-
if (files.length >= maxFiles)
|
|
25
|
-
return;
|
|
26
|
-
try {
|
|
27
|
-
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
28
|
-
for (const entry of entries) {
|
|
29
|
-
if (files.length >= maxFiles)
|
|
30
|
-
return;
|
|
31
|
-
const fullPath = path.join(currentDir, entry.name);
|
|
32
|
-
if (entry.isDirectory()) {
|
|
33
|
-
walk(fullPath);
|
|
34
|
-
}
|
|
35
|
-
else if (entry.name.endsWith('.ly')) {
|
|
36
|
-
files.push(fullPath);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
catch (e) {
|
|
41
|
-
// Skip directories we can't read
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
walk(dir);
|
|
45
|
-
return files;
|
|
46
|
-
};
|
|
47
|
-
const testDecoder = async () => {
|
|
48
|
-
console.log(`Finding LilyPond files in ${LY_FILES_PATH}...`);
|
|
49
|
-
console.log(`Output directory: ${OUTPUT_DIR}\n`);
|
|
50
|
-
// Create output directories
|
|
51
|
-
const jsonDir = path.join(OUTPUT_DIR, 'json');
|
|
52
|
-
const lylDir = path.join(OUTPUT_DIR, 'lyl');
|
|
53
|
-
if (!fs.existsSync(jsonDir)) {
|
|
54
|
-
fs.mkdirSync(jsonDir, { recursive: true });
|
|
55
|
-
}
|
|
56
|
-
if (!fs.existsSync(lylDir)) {
|
|
57
|
-
fs.mkdirSync(lylDir, { recursive: true });
|
|
58
|
-
}
|
|
59
|
-
const lyFiles = findLyFiles(LY_FILES_PATH, MAX_FILES);
|
|
60
|
-
console.log(`Found ${lyFiles.length} .ly files to test\n`);
|
|
61
|
-
let passed = 0;
|
|
62
|
-
let failed = 0;
|
|
63
|
-
for (const filePath of lyFiles) {
|
|
64
|
-
const relativePath = path.relative(LY_FILES_PATH, filePath);
|
|
65
|
-
const baseName = path.basename(filePath, '.ly');
|
|
66
|
-
try {
|
|
67
|
-
const source = fs.readFileSync(filePath, 'utf-8');
|
|
68
|
-
const doc = await decode(source);
|
|
69
|
-
const measureCount = doc.measures.length;
|
|
70
|
-
const voiceCount = doc.measures.reduce((sum, m) => sum + m.parts.reduce((psum, p) => psum + p.voices.length, 0), 0);
|
|
71
|
-
const noteCount = doc.measures.reduce((sum, m) => sum + m.parts.reduce((psum, p) => psum + p.voices.reduce((vsum, v) => vsum + v.events.filter(e => e.type === 'note').length, 0), 0), 0);
|
|
72
|
-
// Write JSON output
|
|
73
|
-
const jsonPath = path.join(jsonDir, `${baseName}.json`);
|
|
74
|
-
fs.writeFileSync(jsonPath, JSON.stringify(doc, null, 2));
|
|
75
|
-
// Write .lyl output using serializer
|
|
76
|
-
const lylContent = serializeLilyletDoc(doc);
|
|
77
|
-
const lylPath = path.join(lylDir, `${baseName}.lyl`);
|
|
78
|
-
fs.writeFileSync(lylPath, lylContent);
|
|
79
|
-
console.log(`✓ ${relativePath}`);
|
|
80
|
-
console.log(` Measures: ${measureCount}, Voices: ${voiceCount}, Notes: ${noteCount}`);
|
|
81
|
-
console.log(` -> ${baseName}.json, ${baseName}.lyl\n`);
|
|
82
|
-
passed++;
|
|
83
|
-
}
|
|
84
|
-
catch (e) {
|
|
85
|
-
console.log(`✗ ${relativePath}`);
|
|
86
|
-
console.log(` Error: ${e.message}\n`);
|
|
87
|
-
failed++;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
console.log('========================================');
|
|
91
|
-
console.log(`Total: ${lyFiles.length}, Passed: ${passed}, Failed: ${failed}`);
|
|
92
|
-
console.log(`JSON output: ${jsonDir}`);
|
|
93
|
-
console.log(`LYL output: ${lylDir}`);
|
|
94
|
-
};
|
|
95
|
-
testDecoder().catch(console.error);
|
package/lib/tests/ly-to-lyl.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/lib/tests/ly-to-lyl.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { serializeLilyletDoc } from "../source/lilylet/index.js";
|
|
2
|
-
import { decode as decodeLilypond } from "../source/lilylet/lilypondDecoder.js";
|
|
3
|
-
import * as fs from "fs";
|
|
4
|
-
const file = process.argv[2];
|
|
5
|
-
if (!file) {
|
|
6
|
-
console.error("Usage: npx tsx tests/ly-to-lyl.ts <file.ly>");
|
|
7
|
-
process.exit(1);
|
|
8
|
-
}
|
|
9
|
-
const source = fs.readFileSync(file, "utf-8");
|
|
10
|
-
const doc = decodeLilypond(source);
|
|
11
|
-
const lyl = serializeLilyletDoc(doc);
|
|
12
|
-
console.log(lyl);
|
package/lib/tests/mei.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|