@k-l-lambda/lilylet 0.1.63 → 0.1.65

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/lib/lilylet/grammar.jison.js +1 -10
  2. package/lib/lilylet/meiEncoder.js +58 -40
  3. package/lib/source/abc/abc.d.ts +102 -0
  4. package/lib/source/abc/abc.js +25 -0
  5. package/lib/source/abc/parser.d.ts +3 -0
  6. package/lib/source/abc/parser.js +6 -0
  7. package/lib/source/lilylet/abcDecoder.d.ts +25 -0
  8. package/lib/source/lilylet/abcDecoder.js +1035 -0
  9. package/lib/source/lilylet/index.d.ts +10 -0
  10. package/lib/source/lilylet/index.js +10 -0
  11. package/lib/source/lilylet/lilypondDecoder.d.ts +29 -0
  12. package/lib/source/lilylet/lilypondDecoder.js +1223 -0
  13. package/lib/source/lilylet/lilypondEncoder.d.ts +34 -0
  14. package/lib/source/lilylet/lilypondEncoder.js +893 -0
  15. package/lib/source/lilylet/meiEncoder.d.ts +8 -0
  16. package/lib/source/lilylet/meiEncoder.js +1985 -0
  17. package/lib/source/lilylet/musicXmlDecoder.d.ts +20 -0
  18. package/lib/source/lilylet/musicXmlDecoder.js +1195 -0
  19. package/lib/source/lilylet/musicXmlEncoder.d.ts +15 -0
  20. package/lib/source/lilylet/musicXmlEncoder.js +701 -0
  21. package/lib/source/lilylet/musicXmlTypes.d.ts +199 -0
  22. package/lib/source/lilylet/musicXmlTypes.js +7 -0
  23. package/lib/source/lilylet/musicXmlUtils.d.ts +92 -0
  24. package/lib/source/lilylet/musicXmlUtils.js +469 -0
  25. package/lib/source/lilylet/parser.d.ts +14 -0
  26. package/lib/source/lilylet/parser.js +161 -0
  27. package/lib/source/lilylet/serializer.d.ts +11 -0
  28. package/lib/source/lilylet/serializer.js +791 -0
  29. package/lib/source/lilylet/types.d.ts +253 -0
  30. package/lib/source/lilylet/types.js +100 -0
  31. package/lib/tests/abc-abcjs-parse.d.ts +8 -0
  32. package/lib/tests/abc-abcjs-parse.js +90 -0
  33. package/lib/tests/abc-abcjs-svg.d.ts +1 -0
  34. package/lib/tests/abc-abcjs-svg.js +143 -0
  35. package/lib/tests/abc-decoder.d.ts +1 -0
  36. package/lib/tests/abc-decoder.js +67 -0
  37. package/lib/tests/abc-mei-compare.d.ts +1 -0
  38. package/lib/tests/abc-mei-compare.js +525 -0
  39. package/lib/tests/auto-beam.d.ts +9 -0
  40. package/lib/tests/auto-beam.js +151 -0
  41. package/lib/tests/computeMeiHashes.d.ts +1 -0
  42. package/lib/tests/computeMeiHashes.js +87 -0
  43. package/lib/tests/encoder-mutation.d.ts +9 -0
  44. package/lib/tests/encoder-mutation.js +110 -0
  45. package/lib/tests/gpt-review-issues.d.ts +5 -0
  46. package/lib/tests/gpt-review-issues.js +255 -0
  47. package/lib/tests/json-to-lyl.d.ts +1 -0
  48. package/lib/tests/json-to-lyl.js +18 -0
  49. package/lib/tests/lilypond-roundtrip.d.ts +7 -0
  50. package/lib/tests/lilypond-roundtrip.js +558 -0
  51. package/lib/tests/lilypondDecoder.d.ts +6 -0
  52. package/lib/tests/lilypondDecoder.js +95 -0
  53. package/lib/tests/ly-to-lyl.d.ts +1 -0
  54. package/lib/tests/ly-to-lyl.js +12 -0
  55. package/lib/tests/mei.d.ts +1 -0
  56. package/lib/tests/mei.js +278 -0
  57. package/lib/tests/musicxml-decoder.d.ts +4 -0
  58. package/lib/tests/musicxml-decoder.js +61 -0
  59. package/lib/tests/musicxml-detail.d.ts +4 -0
  60. package/lib/tests/musicxml-detail.js +85 -0
  61. package/lib/tests/musicxml-fprod.d.ts +9 -0
  62. package/lib/tests/musicxml-fprod.js +153 -0
  63. package/lib/tests/musicxml-roundtrip.d.ts +7 -0
  64. package/lib/tests/musicxml-roundtrip.js +296 -0
  65. package/lib/tests/musicxml-to-mei.d.ts +6 -0
  66. package/lib/tests/musicxml-to-mei.js +115 -0
  67. package/lib/tests/parser.d.ts +1 -0
  68. package/lib/tests/parser.js +17 -0
  69. package/lib/tests/render-k283.d.ts +1 -0
  70. package/lib/tests/render-k283.js +33 -0
  71. package/lib/tests/render-lyl.d.ts +1 -0
  72. package/lib/tests/render-lyl.js +35 -0
  73. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +23 -0
  74. package/lib/tests/unit/afterGraceInsideTuplet.test.js +186 -0
  75. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +21 -0
  76. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +356 -0
  77. package/lib/tests/unit/crossStaffDecoder.test.d.ts +15 -0
  78. package/lib/tests/unit/crossStaffDecoder.test.js +147 -0
  79. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +1 -0
  80. package/lib/tests/unit/crossStaffEdgeCases.test.js +209 -0
  81. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +15 -0
  82. package/lib/tests/unit/crossStaffMultiMeasure.test.js +231 -0
  83. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +11 -0
  84. package/lib/tests/unit/fullMeasureRestDecoder.test.js +154 -0
  85. package/lib/tests/unit/gptReviewIssues.test.d.ts +8 -0
  86. package/lib/tests/unit/gptReviewIssues.test.js +240 -0
  87. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +13 -0
  88. package/lib/tests/unit/parallelMusicDecoder.test.js +261 -0
  89. package/lib/tests/unit/partialWarning.test.d.ts +4 -0
  90. package/lib/tests/unit/partialWarning.test.js +65 -0
  91. package/lib/tests/unit/serializerRoundTrip.test.d.ts +8 -0
  92. package/lib/tests/unit/serializerRoundTrip.test.js +263 -0
  93. package/lib/tests/unit/staffInsideTuplet.test.d.ts +25 -0
  94. package/lib/tests/unit/staffInsideTuplet.test.js +133 -0
  95. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +16 -0
  96. package/lib/tests/unit/timesFirstNoteEscape.test.js +152 -0
  97. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +17 -0
  98. package/lib/tests/unit/tupletWithBaseDuration.test.js +139 -0
  99. package/lib/tests/unit/voiceStaffParsing.test.d.ts +13 -0
  100. package/lib/tests/unit/voiceStaffParsing.test.js +118 -0
  101. package/package.json +1 -1
  102. package/source/lilylet/grammar.jison.js +1 -10
  103. package/source/lilylet/lilylet.jison +1 -10
  104. package/source/lilylet/meiEncoder.ts +65 -40
@@ -0,0 +1,278 @@
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 initVerovio = async () => {
8
+ const VerovioModule = await createVerovioModule();
9
+ return new VerovioToolkit(VerovioModule);
10
+ };
11
+ // Count notes/rests in the source AST, recursing through tuplet/times wrappers.
12
+ const countSourceNotes = (doc) => {
13
+ let count = 0;
14
+ const walk = (events) => {
15
+ for (const e of events) {
16
+ if (!e)
17
+ continue;
18
+ if (e.type === "note" || e.type === "rest")
19
+ count++;
20
+ else if (e.type === "tuplet" || e.type === "times")
21
+ walk(e.events || []);
22
+ else if (e.type === "tremolo")
23
+ count += 2;
24
+ }
25
+ };
26
+ for (const m of doc.measures || []) {
27
+ for (const part of m.parts || []) {
28
+ for (const voice of part.voices || [])
29
+ walk(voice.events || []);
30
+ }
31
+ }
32
+ return count;
33
+ };
34
+ // Count <note>/<rest>/<chord>/<mRest>/<mSpace> elements in MEI output (rough but
35
+ // sufficient for detecting silently-dropped event types that produce empty sections).
36
+ const countMeiNotes = (mei) => {
37
+ const matches = mei.match(/<(note|rest|chord|mRest|mSpace)\b/g);
38
+ return matches ? matches.length : 0;
39
+ };
40
+ const testFile = async (vrvToolkit, filePath) => {
41
+ const file = filePath.split("/").pop();
42
+ try {
43
+ // Step 1: Parse .lyl file
44
+ const code = fs.readFileSync(filePath, { encoding: "utf-8" });
45
+ const doc = lilylet.parseCode(code);
46
+ // Step 2: Encode to MEI
47
+ const mei = lilylet.meiEncoder.encode(doc);
48
+ // Step 2.5: Sanity check — count source notes/rests vs MEI output.
49
+ // Catches silent drops where an event type is unhandled and MEI ends up empty.
50
+ const expected = countSourceNotes(doc);
51
+ const actual = countMeiNotes(mei);
52
+ if (expected > 0 && actual === 0) {
53
+ return {
54
+ file,
55
+ success: false,
56
+ error: `MEI output has 0 notes/rests but source has ${expected} (likely an event type is silently dropped)`,
57
+ mei,
58
+ };
59
+ }
60
+ // Step 3: Calculate pageHeight based on measure count and staff count
61
+ const measureCount = doc.measures?.length || 1;
62
+ // Calculate total staff count
63
+ let staffCount = 1;
64
+ if (doc.measures.length > 0) {
65
+ const firstMeasure = doc.measures[0];
66
+ staffCount = firstMeasure.parts.reduce((total, part) => {
67
+ const maxStaff = part.voices.reduce((max, voice) => Math.max(max, voice.staff || 1), 1);
68
+ return total + maxStaff;
69
+ }, 0) || 1;
70
+ }
71
+ const basePageHeight = 2000;
72
+ const measuresPerPage = 20;
73
+ const pageHeight = Math.max(basePageHeight, Math.ceil(measureCount / measuresPerPage) * basePageHeight) * 2 * staffCount;
74
+ // Step 4: Set Verovio options for single-page rendering
75
+ vrvToolkit.setOptions({
76
+ scale: 40,
77
+ adjustPageHeight: true,
78
+ pageHeight,
79
+ pageWidth: 2100,
80
+ });
81
+ // Step 5: Validate MEI with verovio
82
+ const success = vrvToolkit.loadData(mei);
83
+ if (!success) {
84
+ return {
85
+ file,
86
+ success: false,
87
+ error: "Verovio failed to load MEI data",
88
+ mei,
89
+ };
90
+ }
91
+ // Step 6: Try to render SVG (further validation)
92
+ const svg = vrvToolkit.renderToSVG(1);
93
+ if (!svg || svg.length === 0) {
94
+ return {
95
+ file,
96
+ success: false,
97
+ error: "Verovio failed to render SVG",
98
+ mei,
99
+ };
100
+ }
101
+ return {
102
+ file,
103
+ success: true,
104
+ mei,
105
+ svg,
106
+ };
107
+ }
108
+ catch (err) {
109
+ return {
110
+ file,
111
+ success: false,
112
+ error: err instanceof Error ? err.message : String(err),
113
+ };
114
+ }
115
+ };
116
+ // Generate index.html gallery
117
+ const generateIndexHtml = (files, outputDir, lylDir) => {
118
+ // Group files by category (prefix before first dash or hyphen pattern)
119
+ const getCategory = (file) => {
120
+ const name = file.replace('.lyl', '');
121
+ const parts = name.split('-');
122
+ // Find category: usually first 1-2 parts
123
+ if (parts.length >= 2) {
124
+ // Check common patterns
125
+ if (parts[0] === 'time' && parts[1] === 'signatures')
126
+ return 'time-signatures';
127
+ if (parts[0] === 'key' && parts[1] === 'signatures')
128
+ return 'key-signatures';
129
+ if (parts[0] === 'basic' && parts[1] === 'notes')
130
+ return 'basic-notes';
131
+ if (parts[0] === 'ties' && parts[1] === 'and')
132
+ return 'ties-and-slurs';
133
+ if (parts[0] === 'grace' && parts[1] === 'notes')
134
+ return 'grace-notes';
135
+ if (parts[0] === 'stem' && parts[1] === 'direction')
136
+ return 'stem-direction';
137
+ if (parts[0] === 'multiple' && parts[1] === 'staves')
138
+ return 'multiple-staves';
139
+ if (parts[0] === 'multiple' && parts[1] === 'voices')
140
+ return 'multiple-voices';
141
+ if (parts[0] === 'multiple' && parts[1] === 'measures')
142
+ return 'multiple-measures';
143
+ if (parts[0] === 'multiple' && parts[1] === 'parts')
144
+ return 'multiple-parts';
145
+ return parts[0];
146
+ }
147
+ return parts[0];
148
+ };
149
+ // Escape HTML entities
150
+ const escHtml = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
151
+ const categories = [...new Set(files.map(getCategory))].sort();
152
+ const cards = files.map(file => {
153
+ const name = file.replace('.lyl', '');
154
+ const category = getCategory(file);
155
+ const source = fs.readFileSync(`${lylDir}/${file}`, 'utf-8').trim();
156
+ return ` <div class="card" data-category="${category}">
157
+ <div class="card-header">${name} <span class="category">${category}</span></div>
158
+ <div class="card-source"><code>${escHtml(source)}</code></div>
159
+ <div class="card-body"><img src="${name}.svg" alt="${name}"></div>
160
+ <div class="card-footer"><a href="${name}.mei">MEI</a><a href="${name}.svg" target="_blank">SVG</a></div>
161
+ </div>`;
162
+ }).join('\n');
163
+ const filterButtons = categories.map(cat => `<a href="#${cat}" class="filter-btn" data-filter="${cat}">${cat.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ')}</a>`).join('\n ');
164
+ const html = `<!DOCTYPE html>
165
+ <html lang="en">
166
+ <head>
167
+ <meta charset="UTF-8">
168
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
169
+ <title>Lilylet MEI Test Results</title>
170
+ <style>
171
+ * { box-sizing: border-box; }
172
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 20px; background: #f5f5f5; }
173
+ h1 { text-align: center; color: #333; margin-bottom: 10px; }
174
+ .stats { text-align: center; color: #666; margin-bottom: 30px; }
175
+ .filter-bar { display: flex; justify-content: center; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; }
176
+ .filter-btn { padding: 8px 16px; border: 1px solid #ddd; background: white; border-radius: 20px; cursor: pointer; font-size: 14px; transition: all 0.2s; text-decoration: none; color: #333; }
177
+ .filter-btn:hover { background: #e0e0e0; }
178
+ .filter-btn.active { background: #4a90d9; color: white; border-color: #4a90d9; }
179
+ .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(500px, 1fr)); gap: 20px; }
180
+ .card { background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); overflow: hidden; }
181
+ .card.hidden { display: none; }
182
+ .card-header { padding: 12px 16px; background: #f8f9fa; border-bottom: 1px solid #eee; font-weight: 500; font-size: 14px; }
183
+ .card-header .category { float: right; font-size: 12px; color: #888; font-weight: normal; }
184
+ .card-source { padding: 10px 16px; background: #1e1e1e; border-bottom: 1px solid #eee; }
185
+ .card-source code { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Consolas, monospace; font-size: 13px; color: #d4d4d4; white-space: pre-wrap; word-break: break-all; }
186
+ .card-body { padding: 16px; text-align: center; min-height: 100px; display: flex; align-items: center; justify-content: center; }
187
+ .card-body img { max-width: 100%; height: auto; }
188
+ .card-footer { padding: 12px 16px; background: #f8f9fa; border-top: 1px solid #eee; display: flex; gap: 10px; }
189
+ .card-footer a { color: #4a90d9; text-decoration: none; font-size: 14px; }
190
+ .card-footer a:hover { text-decoration: underline; }
191
+ </style>
192
+ </head>
193
+ <body>
194
+ <h1>Lilylet MEI Test Results</h1>
195
+ <div class="stats">${files.length} test cases</div>
196
+ <div class="filter-bar">
197
+ <a href="#" class="filter-btn active" data-filter="all">All</a>
198
+ ${filterButtons}
199
+ </div>
200
+ <div class="gallery">
201
+ ${cards}
202
+ </div>
203
+ <script>
204
+ function applyFilter(filter) {
205
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
206
+ const activeBtn = document.querySelector('.filter-btn[data-filter="' + filter + '"]');
207
+ if (activeBtn) activeBtn.classList.add('active');
208
+ document.querySelectorAll('.card').forEach(card => {
209
+ if (filter === 'all' || card.dataset.category === filter) {
210
+ card.classList.remove('hidden');
211
+ } else {
212
+ card.classList.add('hidden');
213
+ }
214
+ });
215
+ }
216
+ function handleHash() {
217
+ const hash = window.location.hash.slice(1);
218
+ applyFilter(hash || 'all');
219
+ }
220
+ window.addEventListener('hashchange', handleHash);
221
+ handleHash();
222
+ </script>
223
+ </body>
224
+ </html>`;
225
+ fs.writeFileSync(`${outputDir}/index.html`, html);
226
+ };
227
+ const main = async () => {
228
+ const lylDir = "./tests/assets/unit-cases";
229
+ const outputDir = "./tests/output/unit-cases";
230
+ // Create output directory if not exists
231
+ if (!fs.existsSync(outputDir)) {
232
+ fs.mkdirSync(outputDir, { recursive: true });
233
+ }
234
+ // Initialize verovio
235
+ console.log("Initializing Verovio...");
236
+ const vrvToolkit = await initVerovio();
237
+ console.log("Verovio initialized.\n");
238
+ // Get all .lyl files
239
+ const files = fs.readdirSync(lylDir).filter(f => f.endsWith(".lyl"));
240
+ let passed = 0;
241
+ let failed = 0;
242
+ const failures = [];
243
+ for (const file of files) {
244
+ const result = await testFile(vrvToolkit, `${lylDir}/${file}`);
245
+ if (result.success) {
246
+ console.log(`✓ ${file}`);
247
+ passed++;
248
+ // Optionally save MEI and SVG output
249
+ if (result.mei) {
250
+ fs.writeFileSync(`${outputDir}/${file.replace(".lyl", ".mei")}`, result.mei);
251
+ }
252
+ if (result.svg) {
253
+ fs.writeFileSync(`${outputDir}/${file.replace(".lyl", ".svg")}`, result.svg);
254
+ }
255
+ }
256
+ else {
257
+ console.log(`✗ ${file}: ${result.error}`);
258
+ failed++;
259
+ failures.push(result);
260
+ // Save failed MEI for debugging
261
+ if (result.mei) {
262
+ fs.writeFileSync(`${outputDir}/${file.replace(".lyl", ".mei.failed")}`, result.mei);
263
+ }
264
+ }
265
+ }
266
+ console.log(`\n========================================`);
267
+ console.log(`Total: ${files.length}, Passed: ${passed}, Failed: ${failed}`);
268
+ if (failures.length > 0) {
269
+ console.log(`\nFailed tests:`);
270
+ for (const f of failures) {
271
+ console.log(` - ${f.file}: ${f.error}`);
272
+ }
273
+ process.exit(1);
274
+ }
275
+ // Generate index.html gallery
276
+ generateIndexHtml(files, outputDir, lylDir);
277
+ };
278
+ main();
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Test MusicXML Decoder
3
+ */
4
+ export {};
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Test MusicXML Decoder
3
+ */
4
+ import { musicXmlDecoder, meiEncoder } from '../source/lilylet/index.js';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ const simpleFile = path.join(import.meta.dirname, 'assets/unit-cases/simple.musicxml');
8
+ const complexFile = path.join(import.meta.dirname, 'assets/unit-cases/complex.musicxml');
9
+ async function testFile(filePath, name) {
10
+ console.log(`\n${'='.repeat(60)}`);
11
+ console.log(`Testing: ${name}`);
12
+ console.log('='.repeat(60));
13
+ const xml = fs.readFileSync(filePath, 'utf-8');
14
+ const doc = musicXmlDecoder.decode(xml);
15
+ console.log('\n=== Metadata ===');
16
+ console.log(doc.metadata);
17
+ console.log('\n=== Number of measures ===');
18
+ console.log(doc.measures.length);
19
+ console.log('\n=== First measure ===');
20
+ const firstMeasure = doc.measures[0];
21
+ console.log('Key:', firstMeasure.key);
22
+ console.log('Time:', firstMeasure.timeSig);
23
+ const firstVoice = firstMeasure.parts[0]?.voices[0];
24
+ if (firstVoice) {
25
+ console.log('Events:', firstVoice.events.length);
26
+ for (const event of firstVoice.events) {
27
+ if (event.type === 'note') {
28
+ const pitches = event.pitches.map(p => `${p.phonet}${p.accidental ? '#' : ''}${p.octave}`).join(',');
29
+ const marks = event.marks?.map(m => m.markType).join(', ') || 'none';
30
+ console.log(` Note: ${pitches}, dur=${event.duration.division}, marks=[${marks}]`);
31
+ }
32
+ else if (event.type === 'rest') {
33
+ console.log(` Rest: dur=${event.duration.division}`);
34
+ }
35
+ else if (event.type === 'context') {
36
+ console.log(` Context: clef=${event.clef}`);
37
+ }
38
+ else if (event.type === 'barline') {
39
+ console.log(` Barline: ${event.style}`);
40
+ }
41
+ }
42
+ }
43
+ // Encode to MEI
44
+ const mei = meiEncoder.encode(doc);
45
+ console.log('\n=== MEI Output (truncated) ===');
46
+ console.log(mei.split('\n').slice(0, 30).join('\n') + '\n...');
47
+ return doc;
48
+ }
49
+ async function main() {
50
+ console.log('Testing MusicXML Decoder...');
51
+ try {
52
+ await testFile(simpleFile, 'Simple Test');
53
+ await testFile(complexFile, 'Complex Test (slurs, dynamics, articulations)');
54
+ console.log('\n✅ All MusicXML Decoder tests passed!');
55
+ }
56
+ catch (error) {
57
+ console.error('❌ Test failed:', error);
58
+ process.exit(1);
59
+ }
60
+ }
61
+ main();
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Detailed examination of MusicXML decoder output
3
+ */
4
+ export {};
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Detailed examination of MusicXML decoder output
3
+ */
4
+ import { musicXmlDecoder, meiEncoder } from '../source/lilylet/index.js';
5
+ import * as fs from 'fs';
6
+ import * as path from 'path';
7
+ const MUSICXML_DIR = path.join(import.meta.dirname, 'assets/musicxml');
8
+ // Test file argument
9
+ const testFile = process.argv[2] || 'complex-02-chopin-etude.xml';
10
+ const filepath = path.join(MUSICXML_DIR, testFile);
11
+ console.log(`Examining: ${testFile}\n`);
12
+ const xml = fs.readFileSync(filepath, 'utf-8');
13
+ const doc = musicXmlDecoder.decode(xml);
14
+ console.log('=== Document Structure ===');
15
+ console.log('Metadata:', doc.metadata);
16
+ console.log('Total measures:', doc.measures.length);
17
+ // First measure
18
+ const m1 = doc.measures[0];
19
+ console.log('\n=== First Measure ===');
20
+ console.log('Key:', m1.key);
21
+ console.log('Time:', m1.timeSig);
22
+ console.log('Parts:', m1.parts.length);
23
+ console.log('Voices:', m1.parts[0].voices.length);
24
+ const v1 = m1.parts[0].voices[0];
25
+ console.log('\nFirst voice events (first 10):');
26
+ for (let i = 0; i < Math.min(10, v1.events.length); i++) {
27
+ const e = v1.events[i];
28
+ if (e.type === 'note') {
29
+ const pitches = e.pitches.map(p => `${p.phonet}${p.accidental || ''}${p.octave}`).join('+');
30
+ const marks = e.marks?.map(m => m.markType).join(',') || '-';
31
+ console.log(` [${i}] Note: ${pitches.padEnd(15)} dur=${e.duration.division.toString().padStart(2)} marks=${marks}`);
32
+ }
33
+ else if (e.type === 'rest') {
34
+ console.log(` [${i}] Rest: dur=${e.duration.division}`);
35
+ }
36
+ else if (e.type === 'context') {
37
+ const ctx = [];
38
+ if (e.clef)
39
+ ctx.push(`clef=${e.clef}`);
40
+ if (e.stemDirection)
41
+ ctx.push(`stem=${e.stemDirection}`);
42
+ if (e.ottava !== undefined)
43
+ ctx.push(`ottava=${e.ottava}`);
44
+ console.log(` [${i}] Context: ${ctx.join(', ')}`);
45
+ }
46
+ }
47
+ // Count statistics
48
+ let totalNotes = 0;
49
+ let totalRests = 0;
50
+ let totalChords = 0;
51
+ const markCounts = {};
52
+ for (const measure of doc.measures) {
53
+ for (const part of measure.parts) {
54
+ for (const voice of part.voices) {
55
+ for (const event of voice.events) {
56
+ if (event.type === 'note') {
57
+ totalNotes++;
58
+ if (event.pitches.length > 1)
59
+ totalChords++;
60
+ if (event.marks) {
61
+ for (const mark of event.marks) {
62
+ markCounts[mark.markType] = (markCounts[mark.markType] || 0) + 1;
63
+ }
64
+ }
65
+ }
66
+ else if (event.type === 'rest') {
67
+ totalRests++;
68
+ }
69
+ }
70
+ }
71
+ }
72
+ }
73
+ console.log('\n=== Statistics ===');
74
+ console.log(`Notes: ${totalNotes}`);
75
+ console.log(`Chords: ${totalChords}`);
76
+ console.log(`Rests: ${totalRests}`);
77
+ console.log('\nMark counts:');
78
+ for (const [mark, count] of Object.entries(markCounts).sort((a, b) => b[1] - a[1])) {
79
+ console.log(` ${mark}: ${count}`);
80
+ }
81
+ // Encode to MEI and show snippet
82
+ console.log('\n=== MEI Output (first 40 lines) ===');
83
+ const mei = meiEncoder.encode(doc);
84
+ console.log(mei.split('\n').slice(0, 40).join('\n'));
85
+ console.log('...');
@@ -0,0 +1,9 @@
1
+ /**
2
+ * MusicXML Decoder Unit Tests - Real-world fprod files
3
+ *
4
+ * Tests the decoder against 10 files from fprod:
5
+ * - 5 simple (Thompson beginner pieces)
6
+ * - 3 medium (Thompson intermediate)
7
+ * - 2 complex (Bach Invention, Chopin Etude)
8
+ */
9
+ export {};
@@ -0,0 +1,153 @@
1
+ /**
2
+ * MusicXML Decoder Unit Tests - Real-world fprod files
3
+ *
4
+ * Tests the decoder against 10 files from fprod:
5
+ * - 5 simple (Thompson beginner pieces)
6
+ * - 3 medium (Thompson intermediate)
7
+ * - 2 complex (Bach Invention, Chopin Etude)
8
+ */
9
+ import { musicXmlDecoder, meiEncoder } from '../source/lilylet/index.js';
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ const MUSICXML_DIR = path.join(import.meta.dirname, 'assets/musicxml');
13
+ function countNotes(doc) {
14
+ let count = 0;
15
+ for (const measure of doc.measures) {
16
+ for (const part of measure.parts) {
17
+ for (const voice of part.voices) {
18
+ for (const event of voice.events) {
19
+ if (event.type === 'note') {
20
+ count += event.pitches.length;
21
+ }
22
+ }
23
+ }
24
+ }
25
+ }
26
+ return count;
27
+ }
28
+ function getCategory(filename) {
29
+ if (filename.startsWith('simple-'))
30
+ return 'simple';
31
+ if (filename.startsWith('medium-'))
32
+ return 'medium';
33
+ return 'complex';
34
+ }
35
+ async function testFile(filename) {
36
+ const filepath = path.join(MUSICXML_DIR, filename);
37
+ const category = getCategory(filename);
38
+ const warnings = [];
39
+ // Capture console warnings
40
+ const originalWarn = console.warn;
41
+ console.warn = (...args) => {
42
+ warnings.push(args.join(' '));
43
+ };
44
+ try {
45
+ const xml = fs.readFileSync(filepath, 'utf-8');
46
+ const doc = musicXmlDecoder.decode(xml);
47
+ // Validate basic structure
48
+ if (!doc.measures || doc.measures.length === 0) {
49
+ throw new Error('No measures found in decoded document');
50
+ }
51
+ const notes = countNotes(doc);
52
+ if (notes === 0) {
53
+ throw new Error('No notes found in decoded document');
54
+ }
55
+ // Try encoding to MEI
56
+ const mei = meiEncoder.encode(doc);
57
+ if (!mei.includes('<mei')) {
58
+ throw new Error('MEI encoding failed - no mei tag found');
59
+ }
60
+ console.warn = originalWarn;
61
+ return {
62
+ name: filename,
63
+ category,
64
+ success: true,
65
+ measures: doc.measures.length,
66
+ notes,
67
+ warnings,
68
+ };
69
+ }
70
+ catch (error) {
71
+ console.warn = originalWarn;
72
+ return {
73
+ name: filename,
74
+ category,
75
+ success: false,
76
+ measures: 0,
77
+ notes: 0,
78
+ error: error.message,
79
+ warnings,
80
+ };
81
+ }
82
+ }
83
+ async function main() {
84
+ console.log('MusicXML Decoder Unit Tests - fprod files\n');
85
+ console.log('='.repeat(80));
86
+ const files = fs.readdirSync(MUSICXML_DIR).filter(f => f.endsWith('.xml')).sort();
87
+ const results = [];
88
+ let passed = 0;
89
+ let failed = 0;
90
+ for (const file of files) {
91
+ const result = await testFile(file);
92
+ results.push(result);
93
+ const status = result.success ? '✅' : '❌';
94
+ const stats = result.success
95
+ ? `measures=${result.measures}, notes=${result.notes}`
96
+ : `error: ${result.error}`;
97
+ console.log(`${status} [${result.category.padEnd(7)}] ${result.name}`);
98
+ console.log(` ${stats}`);
99
+ if (result.warnings.length > 0) {
100
+ console.log(` ⚠️ ${result.warnings.length} warnings`);
101
+ }
102
+ if (result.success) {
103
+ passed++;
104
+ }
105
+ else {
106
+ failed++;
107
+ }
108
+ }
109
+ console.log('\n' + '='.repeat(80));
110
+ console.log('Summary:');
111
+ console.log(` Total: ${files.length}`);
112
+ console.log(` Passed: ${passed} ✅`);
113
+ console.log(` Failed: ${failed} ❌`);
114
+ // Group by category
115
+ const byCategory = {
116
+ simple: results.filter(r => r.category === 'simple'),
117
+ medium: results.filter(r => r.category === 'medium'),
118
+ complex: results.filter(r => r.category === 'complex'),
119
+ };
120
+ console.log('\nBy Category:');
121
+ for (const [cat, catResults] of Object.entries(byCategory)) {
122
+ const catPassed = catResults.filter(r => r.success).length;
123
+ console.log(` ${cat}: ${catPassed}/${catResults.length}`);
124
+ }
125
+ // Show failed tests
126
+ const failedTests = results.filter(r => !r.success);
127
+ if (failedTests.length > 0) {
128
+ console.log('\nFailed Tests:');
129
+ for (const test of failedTests) {
130
+ console.log(` ❌ ${test.name}: ${test.error}`);
131
+ }
132
+ }
133
+ // Statistics for successful tests
134
+ const successfulTests = results.filter(r => r.success);
135
+ if (successfulTests.length > 0) {
136
+ const totalMeasures = successfulTests.reduce((sum, r) => sum + r.measures, 0);
137
+ const totalNotes = successfulTests.reduce((sum, r) => sum + r.notes, 0);
138
+ console.log('\nStatistics (successful tests):');
139
+ console.log(` Total measures: ${totalMeasures}`);
140
+ console.log(` Total notes: ${totalNotes}`);
141
+ console.log(` Avg measures/file: ${(totalMeasures / successfulTests.length).toFixed(1)}`);
142
+ console.log(` Avg notes/file: ${(totalNotes / successfulTests.length).toFixed(1)}`);
143
+ }
144
+ console.log('\n' + '='.repeat(80));
145
+ if (failed > 0) {
146
+ console.log(`\n❌ ${failed} test(s) failed`);
147
+ process.exit(1);
148
+ }
149
+ else {
150
+ console.log('\n✅ All tests passed!');
151
+ }
152
+ }
153
+ main();
@@ -0,0 +1,7 @@
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
+ export {};