@k-l-lambda/lilylet 0.1.71 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/lib/highlight.d.ts +1 -0
  2. package/lib/highlight.js +1 -0
  3. package/lib/lilylet/highlight.d.ts +29 -0
  4. package/lib/lilylet/highlight.js +145 -0
  5. package/package.json +8 -2
  6. package/source/lilylet/highlight.ts +192 -0
  7. package/lib/source/abc/abc.d.ts +0 -102
  8. package/lib/source/abc/abc.js +0 -25
  9. package/lib/source/abc/parser.d.ts +0 -3
  10. package/lib/source/abc/parser.js +0 -6
  11. package/lib/source/lilylet/abcDecoder.d.ts +0 -25
  12. package/lib/source/lilylet/abcDecoder.js +0 -1035
  13. package/lib/source/lilylet/index.d.ts +0 -10
  14. package/lib/source/lilylet/index.js +0 -10
  15. package/lib/source/lilylet/lilypondDecoder.d.ts +0 -29
  16. package/lib/source/lilylet/lilypondDecoder.js +0 -1223
  17. package/lib/source/lilylet/lilypondEncoder.d.ts +0 -34
  18. package/lib/source/lilylet/lilypondEncoder.js +0 -893
  19. package/lib/source/lilylet/meiEncoder.d.ts +0 -8
  20. package/lib/source/lilylet/meiEncoder.js +0 -1985
  21. package/lib/source/lilylet/musicXmlDecoder.d.ts +0 -20
  22. package/lib/source/lilylet/musicXmlDecoder.js +0 -1195
  23. package/lib/source/lilylet/musicXmlEncoder.d.ts +0 -15
  24. package/lib/source/lilylet/musicXmlEncoder.js +0 -701
  25. package/lib/source/lilylet/musicXmlTypes.d.ts +0 -199
  26. package/lib/source/lilylet/musicXmlTypes.js +0 -7
  27. package/lib/source/lilylet/musicXmlUtils.d.ts +0 -92
  28. package/lib/source/lilylet/musicXmlUtils.js +0 -469
  29. package/lib/source/lilylet/parser.d.ts +0 -14
  30. package/lib/source/lilylet/parser.js +0 -161
  31. package/lib/source/lilylet/serializer.d.ts +0 -11
  32. package/lib/source/lilylet/serializer.js +0 -791
  33. package/lib/source/lilylet/types.d.ts +0 -253
  34. package/lib/source/lilylet/types.js +0 -100
  35. package/lib/tests/abc-abcjs-parse.d.ts +0 -8
  36. package/lib/tests/abc-abcjs-parse.js +0 -90
  37. package/lib/tests/abc-abcjs-svg.d.ts +0 -1
  38. package/lib/tests/abc-abcjs-svg.js +0 -143
  39. package/lib/tests/abc-decoder.d.ts +0 -1
  40. package/lib/tests/abc-decoder.js +0 -67
  41. package/lib/tests/abc-mei-compare.d.ts +0 -1
  42. package/lib/tests/abc-mei-compare.js +0 -525
  43. package/lib/tests/auto-beam.d.ts +0 -9
  44. package/lib/tests/auto-beam.js +0 -151
  45. package/lib/tests/computeMeiHashes.d.ts +0 -1
  46. package/lib/tests/computeMeiHashes.js +0 -87
  47. package/lib/tests/encoder-mutation.d.ts +0 -9
  48. package/lib/tests/encoder-mutation.js +0 -110
  49. package/lib/tests/gpt-review-issues.d.ts +0 -5
  50. package/lib/tests/gpt-review-issues.js +0 -255
  51. package/lib/tests/json-to-lyl.d.ts +0 -1
  52. package/lib/tests/json-to-lyl.js +0 -18
  53. package/lib/tests/lilypond-roundtrip.d.ts +0 -7
  54. package/lib/tests/lilypond-roundtrip.js +0 -558
  55. package/lib/tests/lilypondDecoder.d.ts +0 -6
  56. package/lib/tests/lilypondDecoder.js +0 -95
  57. package/lib/tests/ly-to-lyl.d.ts +0 -1
  58. package/lib/tests/ly-to-lyl.js +0 -12
  59. package/lib/tests/mei.d.ts +0 -1
  60. package/lib/tests/mei.js +0 -278
  61. package/lib/tests/musicxml-decoder.d.ts +0 -4
  62. package/lib/tests/musicxml-decoder.js +0 -61
  63. package/lib/tests/musicxml-detail.d.ts +0 -4
  64. package/lib/tests/musicxml-detail.js +0 -85
  65. package/lib/tests/musicxml-fprod.d.ts +0 -9
  66. package/lib/tests/musicxml-fprod.js +0 -153
  67. package/lib/tests/musicxml-roundtrip.d.ts +0 -7
  68. package/lib/tests/musicxml-roundtrip.js +0 -296
  69. package/lib/tests/musicxml-to-mei.d.ts +0 -6
  70. package/lib/tests/musicxml-to-mei.js +0 -115
  71. package/lib/tests/parser.d.ts +0 -1
  72. package/lib/tests/parser.js +0 -17
  73. package/lib/tests/render-k283.d.ts +0 -1
  74. package/lib/tests/render-k283.js +0 -33
  75. package/lib/tests/render-lyl.d.ts +0 -1
  76. package/lib/tests/render-lyl.js +0 -35
  77. package/lib/tests/unit/afterGraceInsideTuplet.test.d.ts +0 -23
  78. package/lib/tests/unit/afterGraceInsideTuplet.test.js +0 -186
  79. package/lib/tests/unit/changeStaffBeforeTuplet.test.d.ts +0 -21
  80. package/lib/tests/unit/changeStaffBeforeTuplet.test.js +0 -356
  81. package/lib/tests/unit/crossStaffDecoder.test.d.ts +0 -15
  82. package/lib/tests/unit/crossStaffDecoder.test.js +0 -147
  83. package/lib/tests/unit/crossStaffEdgeCases.test.d.ts +0 -1
  84. package/lib/tests/unit/crossStaffEdgeCases.test.js +0 -209
  85. package/lib/tests/unit/crossStaffMultiMeasure.test.d.ts +0 -15
  86. package/lib/tests/unit/crossStaffMultiMeasure.test.js +0 -231
  87. package/lib/tests/unit/fullMeasureRestDecoder.test.d.ts +0 -11
  88. package/lib/tests/unit/fullMeasureRestDecoder.test.js +0 -154
  89. package/lib/tests/unit/gptReviewIssues.test.d.ts +0 -8
  90. package/lib/tests/unit/gptReviewIssues.test.js +0 -240
  91. package/lib/tests/unit/parallelMusicDecoder.test.d.ts +0 -13
  92. package/lib/tests/unit/parallelMusicDecoder.test.js +0 -261
  93. package/lib/tests/unit/partialWarning.test.d.ts +0 -4
  94. package/lib/tests/unit/partialWarning.test.js +0 -65
  95. package/lib/tests/unit/serializerRoundTrip.test.d.ts +0 -8
  96. package/lib/tests/unit/serializerRoundTrip.test.js +0 -263
  97. package/lib/tests/unit/staffInsideTuplet.test.d.ts +0 -25
  98. package/lib/tests/unit/staffInsideTuplet.test.js +0 -133
  99. package/lib/tests/unit/timesFirstNoteEscape.test.d.ts +0 -16
  100. package/lib/tests/unit/timesFirstNoteEscape.test.js +0 -152
  101. package/lib/tests/unit/tupletWithBaseDuration.test.d.ts +0 -17
  102. package/lib/tests/unit/tupletWithBaseDuration.test.js +0 -139
  103. package/lib/tests/unit/voiceStaffParsing.test.d.ts +0 -13
  104. package/lib/tests/unit/voiceStaffParsing.test.js +0 -118
@@ -1,151 +0,0 @@
1
- /**
2
- * Test: Auto-beam on/off/auto modes
3
- *
4
- * Verifies that the autoBeam metadata option controls beam generation:
5
- * - 'off': never auto-beam, even without manual beams
6
- * - 'on': always auto-beam, even when manual beams exist
7
- * - 'auto' / undefined: auto-beam only if no manual beam marks in source
8
- */
9
- import { parseCode, meiEncoder } from "../source/lilylet/index.js";
10
- let passed = 0;
11
- let failed = 0;
12
- const assert = (condition, label, detail) => {
13
- if (condition) {
14
- console.log(`✅ ${label}`);
15
- passed++;
16
- }
17
- else {
18
- console.log(`❌ ${label}`);
19
- if (detail)
20
- console.log(` ${detail}`);
21
- failed++;
22
- }
23
- };
24
- const countBeams = (mei) => (mei.match(/<beam /g) || []).length;
25
- console.log("Auto-Beam Mode Tests\n");
26
- console.log("=".repeat(80));
27
- // --- Test source without manual beams ---
28
- console.log("\n--- Source without manual beams: c8 d e f g a b c ---\n");
29
- {
30
- // undefined (default) → should auto-beam
31
- const doc = parseCode("\\clef \"treble\" c8 d e f g a b c'");
32
- const mei = meiEncoder.encode(doc);
33
- const beams = countBeams(mei);
34
- assert(beams > 0, "autoBeam=undefined, no manual beams → auto-beam applied", `beam count: ${beams}`);
35
- }
36
- {
37
- // 'auto' → should auto-beam (same as undefined)
38
- const doc = parseCode("\\clef \"treble\" c8 d e f g a b c'");
39
- doc.metadata = { autoBeam: 'auto' };
40
- const mei = meiEncoder.encode(doc);
41
- const beams = countBeams(mei);
42
- assert(beams > 0, "autoBeam='auto', no manual beams → auto-beam applied", `beam count: ${beams}`);
43
- }
44
- {
45
- // 'on' → should auto-beam
46
- const doc = parseCode("\\clef \"treble\" c8 d e f g a b c'");
47
- doc.metadata = { autoBeam: 'on' };
48
- const mei = meiEncoder.encode(doc);
49
- const beams = countBeams(mei);
50
- assert(beams > 0, "autoBeam='on', no manual beams → auto-beam applied", `beam count: ${beams}`);
51
- }
52
- {
53
- // 'off' → should NOT auto-beam
54
- const doc = parseCode("\\clef \"treble\" c8 d e f g a b c'");
55
- doc.metadata = { autoBeam: 'off' };
56
- const mei = meiEncoder.encode(doc);
57
- const beams = countBeams(mei);
58
- assert(beams === 0, "autoBeam='off', no manual beams → no beams", `beam count: ${beams}`);
59
- }
60
- // --- Test source WITH manual beams ---
61
- console.log("\n--- Source with manual beams: c8[ d e f] g a b c ---\n");
62
- {
63
- // undefined → should NOT auto-beam (manual beams detected)
64
- const doc = parseCode("\\clef \"treble\" c8[ d e f] g a b c'");
65
- const mei = meiEncoder.encode(doc);
66
- const beams = countBeams(mei);
67
- assert(beams === 1, "autoBeam=undefined, has manual beams → only manual beams (1)", `beam count: ${beams}`);
68
- }
69
- {
70
- // 'auto' → should NOT auto-beam (manual beams detected)
71
- const doc = parseCode("\\clef \"treble\" c8[ d e f] g a b c'");
72
- doc.metadata = { autoBeam: 'auto' };
73
- const mei = meiEncoder.encode(doc);
74
- const beams = countBeams(mei);
75
- assert(beams === 1, "autoBeam='auto', has manual beams → only manual beams (1)", `beam count: ${beams}`);
76
- }
77
- {
78
- // 'on' → should auto-beam even though manual beams exist
79
- const doc = parseCode("\\clef \"treble\" c8[ d e f] g a b c'");
80
- doc.metadata = { autoBeam: 'on' };
81
- const mei = meiEncoder.encode(doc);
82
- const beams = countBeams(mei);
83
- assert(beams > 1, "autoBeam='on', has manual beams → auto-beam adds more beams", `beam count: ${beams}`);
84
- }
85
- {
86
- // 'off' → should NOT auto-beam, but manual beams kept
87
- const doc = parseCode("\\clef \"treble\" c8[ d e f] g a b c'");
88
- doc.metadata = { autoBeam: 'off' };
89
- const mei = meiEncoder.encode(doc);
90
- const beams = countBeams(mei);
91
- assert(beams === 1, "autoBeam='off', has manual beams → only manual beams (1)", `beam count: ${beams}`);
92
- }
93
- // --- Time signature specific tests ---
94
- console.log("\n--- Time signature grouping ---\n");
95
- {
96
- // 6/8: groups of 3 eighths
97
- const doc = parseCode("\\time 6/8 \\clef \"treble\" c8 d e f g a");
98
- const mei = meiEncoder.encode(doc);
99
- const beams = countBeams(mei);
100
- assert(beams === 2, "6/8: 6 eighths → 2 beam groups (3+3)", `beam count: ${beams}`);
101
- }
102
- {
103
- // 3/4: groups of 3 eighths
104
- const doc = parseCode("\\time 3/4 \\clef \"treble\" c8 d e f g a");
105
- const mei = meiEncoder.encode(doc);
106
- const beams = countBeams(mei);
107
- assert(beams === 2, "3/4: 6 eighths → 2 beam groups (3+3)", `beam count: ${beams}`);
108
- }
109
- {
110
- // 2/4: groups of 2 eighths
111
- const doc = parseCode("\\time 2/4 \\clef \"treble\" c8 d e f");
112
- const mei = meiEncoder.encode(doc);
113
- const beams = countBeams(mei);
114
- assert(beams === 2, "2/4: 4 eighths → 2 beam groups (2+2)", `beam count: ${beams}`);
115
- }
116
- {
117
- // Rest breaks beam
118
- const doc = parseCode("\\clef \"treble\" c8 d r e f g r a");
119
- const mei = meiEncoder.encode(doc);
120
- const beams = countBeams(mei);
121
- assert(beams === 2, "4/4: rests break beams → 2 beam groups", `beam count: ${beams}`);
122
- }
123
- {
124
- // Single beamable note should NOT create beam
125
- const doc = parseCode("\\clef \"treble\" c4 d8 e4 f4");
126
- const mei = meiEncoder.encode(doc);
127
- const beams = countBeams(mei);
128
- assert(beams === 0, "Lone eighth among quarters → no beam", `beam count: ${beams}`);
129
- }
130
- {
131
- // Tuplet auto-beam
132
- const doc = parseCode("\\clef \"treble\" \\times 2/3 { c8 d e } \\times 2/3 { f8 g a } c4 c4");
133
- const mei = meiEncoder.encode(doc);
134
- const beams = countBeams(mei);
135
- assert(beams >= 1, "Tuplet eighths → at least 1 beam group", `beam count: ${beams}`);
136
- }
137
- // --- Idempotency: auto-beam should not double-beam ---
138
- console.log("\n--- Idempotency ---\n");
139
- {
140
- const doc = parseCode("\\clef \"treble\" c8 d e f g a b c'");
141
- const mei1 = meiEncoder.encode(doc);
142
- const mei2 = meiEncoder.encode(doc);
143
- // Note: encode adds beam marks in-place, so second call should still work
144
- // The beam count may differ because marks accumulate, but MEI output structure should be valid
145
- const beams1 = countBeams(mei1);
146
- const beams2 = countBeams(mei2);
147
- assert(beams1 === beams2, "Double-encode produces same beam count", `first: ${beams1}, second: ${beams2}`);
148
- }
149
- console.log("\n" + "=".repeat(80));
150
- console.log(`\nResults: ${passed} passed, ${failed} failed`);
151
- process.exit(failed > 0 ? 1 : 0);
@@ -1 +0,0 @@
1
- export {};
@@ -1,87 +0,0 @@
1
- import * as fs from "fs";
2
- import * as path from "path";
3
- import * as crypto from "crypto";
4
- import * as lilylet from "../source/lilylet/index.js";
5
- const UNIT_CASES_DIR = path.join(import.meta.dirname, "assets/unit-cases");
6
- const OUTPUT_FILE = path.join(import.meta.dirname, "assets/mei-hashes.yaml");
7
- const updateMode = process.argv.includes("--update");
8
- // Parse existing yaml as a flat key:value map (no nesting, comments-aware).
9
- const loadExisting = (file) => {
10
- const map = new Map();
11
- if (!fs.existsSync(file))
12
- return map;
13
- const text = fs.readFileSync(file, "utf-8");
14
- for (const raw of text.split("\n")) {
15
- const line = raw.trim();
16
- if (!line || line.startsWith("#"))
17
- continue;
18
- const m = line.match(/^([^:]+):\s*(.+)$/);
19
- if (m)
20
- map.set(m[1].trim(), m[2].trim());
21
- }
22
- return map;
23
- };
24
- const previous = loadExisting(OUTPUT_FILE);
25
- const files = fs.readdirSync(UNIT_CASES_DIR).filter(f => f.endsWith(".lyl")).sort();
26
- const lines = [
27
- "# MD5 hashes of MEI output for each .lyl unit case.",
28
- "# Generated by tests/computeMeiHashes.ts — regenerate with `--update` after intentional encoder changes.",
29
- "",
30
- ];
31
- const mismatches = [];
32
- const added = [];
33
- for (const file of files) {
34
- const lyl = fs.readFileSync(path.join(UNIT_CASES_DIR, file), "utf-8");
35
- const doc = lilylet.parseCode(lyl);
36
- const mei = lilylet.meiEncoder.encode(doc);
37
- // Strip xml:id attributes and ID references (#note-..., #chord-...) — these
38
- // embed Math.random()-derived sessionPrefix and change between runs.
39
- const normalized = mei
40
- .replace(/\s+xml:id="[^"]*"/g, "")
41
- .replace(/="#[^"]*"/g, '="#"');
42
- const hash = crypto.createHash("md5").update(normalized).digest("hex");
43
- lines.push(`${file}: ${hash}`);
44
- const prev = previous.get(file);
45
- if (prev === undefined) {
46
- added.push(file);
47
- }
48
- else if (prev !== hash) {
49
- mismatches.push({ file, expected: prev, actual: hash });
50
- }
51
- }
52
- const removed = [];
53
- for (const key of previous.keys()) {
54
- if (!files.includes(key))
55
- removed.push(key);
56
- }
57
- if (updateMode) {
58
- fs.writeFileSync(OUTPUT_FILE, lines.join("\n") + "\n");
59
- console.log(`Wrote ${files.length} hashes to ${OUTPUT_FILE}`);
60
- if (mismatches.length)
61
- console.log(`(${mismatches.length} hash(es) changed, ${added.length} added, ${removed.length} removed)`);
62
- process.exit(0);
63
- }
64
- if (mismatches.length === 0 && added.length === 0 && removed.length === 0) {
65
- console.log(`OK: ${files.length} MEI hashes match the recorded values.`);
66
- process.exit(0);
67
- }
68
- if (mismatches.length > 0) {
69
- console.error(`MEI hash mismatch in ${mismatches.length} file(s):`);
70
- for (const m of mismatches) {
71
- console.error(` - ${m.file}`);
72
- console.error(` expected: ${m.expected}`);
73
- console.error(` actual: ${m.actual}`);
74
- }
75
- }
76
- if (added.length > 0) {
77
- console.error(`New file(s) without recorded hash (${added.length}):`);
78
- for (const f of added)
79
- console.error(` - ${f}`);
80
- }
81
- if (removed.length > 0) {
82
- console.error(`Recorded hash for missing file(s) (${removed.length}):`);
83
- for (const f of removed)
84
- console.error(` - ${f}`);
85
- }
86
- console.error(`\nIf the change is intentional, run: npx tsx tests/computeMeiHashes.ts --update`);
87
- process.exit(1);
@@ -1,9 +0,0 @@
1
- /**
2
- * Test: Encoder should not mutate input AST
3
- *
4
- * GPT-5.2 flagged that the encoder temporarily mutates subEvent.duration.tuplet
5
- * during tuplet encoding. This test verifies:
6
- * 1. Encoding a doc twice produces identical output
7
- * 2. The doc's tuplet events are unchanged after encoding
8
- */
9
- export {};
@@ -1,110 +0,0 @@
1
- /**
2
- * Test: Encoder should not mutate input AST
3
- *
4
- * GPT-5.2 flagged that the encoder temporarily mutates subEvent.duration.tuplet
5
- * during tuplet encoding. This test verifies:
6
- * 1. Encoding a doc twice produces identical output
7
- * 2. The doc's tuplet events are unchanged after encoding
8
- */
9
- import * as fs from "fs";
10
- import * as path from "path";
11
- import { parseCode, musicXmlEncoder } from "../source/lilylet/index.js";
12
- const UNIT_CASES_DIR = path.join(import.meta.dirname, "assets/unit-cases");
13
- // Collect all .lyl files that contain tuplets
14
- const files = fs.readdirSync(UNIT_CASES_DIR)
15
- .filter(f => f.endsWith(".lyl"))
16
- .sort();
17
- let passed = 0;
18
- let failed = 0;
19
- console.log("Encoder Mutation Test\n");
20
- console.log("=".repeat(80));
21
- for (const filename of files) {
22
- const filepath = path.join(UNIT_CASES_DIR, filename);
23
- const lyl = fs.readFileSync(filepath, "utf-8");
24
- const doc = parseCode(lyl);
25
- if (!doc || doc.measures.length === 0)
26
- continue;
27
- // Check if doc has tuplets
28
- let hasTuplets = false;
29
- for (const m of doc.measures) {
30
- for (const p of m.parts) {
31
- for (const v of p.voices) {
32
- for (const e of v.events) {
33
- if (e.type === 'tuplet')
34
- hasTuplets = true;
35
- }
36
- }
37
- }
38
- }
39
- if (!hasTuplets)
40
- continue;
41
- // Snapshot tuplet sub-event durations before encoding
42
- const snapshotBefore = [];
43
- for (const m of doc.measures) {
44
- for (const p of m.parts) {
45
- for (const v of p.voices) {
46
- for (const e of v.events) {
47
- if (e.type === 'tuplet') {
48
- for (const sub of e.events) {
49
- snapshotBefore.push(JSON.stringify(sub.duration));
50
- }
51
- }
52
- }
53
- }
54
- }
55
- }
56
- // Encode first time
57
- const xml1 = musicXmlEncoder.encode(doc);
58
- // Snapshot after first encode
59
- const snapshotAfter = [];
60
- for (const m of doc.measures) {
61
- for (const p of m.parts) {
62
- for (const v of p.voices) {
63
- for (const e of v.events) {
64
- if (e.type === 'tuplet') {
65
- for (const sub of e.events) {
66
- snapshotAfter.push(JSON.stringify(sub.duration));
67
- }
68
- }
69
- }
70
- }
71
- }
72
- }
73
- // Encode second time
74
- const xml2 = musicXmlEncoder.encode(doc);
75
- // Check 1: Duration snapshots unchanged
76
- let durationMutated = false;
77
- if (snapshotBefore.length !== snapshotAfter.length) {
78
- durationMutated = true;
79
- }
80
- else {
81
- for (let i = 0; i < snapshotBefore.length; i++) {
82
- if (snapshotBefore[i] !== snapshotAfter[i]) {
83
- durationMutated = true;
84
- console.log(` Duration mutated at index ${i}:`);
85
- console.log(` Before: ${snapshotBefore[i]}`);
86
- console.log(` After: ${snapshotAfter[i]}`);
87
- break;
88
- }
89
- }
90
- }
91
- // Check 2: Identical output
92
- const identicalOutput = xml1 === xml2;
93
- if (!durationMutated && identicalOutput) {
94
- console.log(`✅ ${filename}`);
95
- passed++;
96
- }
97
- else {
98
- console.log(`❌ ${filename}`);
99
- if (durationMutated) {
100
- console.log(` AST mutation detected: tuplet sub-event duration changed after encode`);
101
- }
102
- if (!identicalOutput) {
103
- console.log(` Non-idempotent: second encode produced different output`);
104
- }
105
- failed++;
106
- }
107
- }
108
- console.log("\n" + "=".repeat(80));
109
- console.log(`\nResults: ${passed} passed, ${failed} failed`);
110
- process.exit(failed > 0 ? 1 : 0);
@@ -1,5 +0,0 @@
1
- /**
2
- * Tests for specific issues raised by GPT-5.2 code review.
3
- * Tests edge cases that the roundtrip test may not cover.
4
- */
5
- export {};
@@ -1,255 +0,0 @@
1
- /**
2
- * Tests for specific issues raised by GPT-5.2 code review.
3
- * Tests edge cases that the roundtrip test may not cover.
4
- */
5
- import { parseCode, musicXmlEncoder, musicXmlDecoder } from "../source/lilylet/index.js";
6
- let passed = 0;
7
- let failed = 0;
8
- const assert = (condition, name, detail) => {
9
- if (condition) {
10
- console.log(`✅ ${name}`);
11
- passed++;
12
- }
13
- else {
14
- console.log(`❌ ${name}`);
15
- if (detail)
16
- console.log(` ${detail}`);
17
- failed++;
18
- }
19
- };
20
- console.log("GPT-5.2 Review Issues Test\n");
21
- console.log("=".repeat(80));
22
- // ============================================================
23
- // Issue 1: Tuplet ratio direction consistency
24
- // GPT says decoder swaps numerator/denominator and default {2,3} may be wrong
25
- // ============================================================
26
- console.log("\n--- Tuplet Ratio Direction ---\n");
27
- {
28
- // A triplet in Lilylet: \times 2/3 {c8 d e} means 3 notes in the time of 2
29
- // Lilylet TupletEvent.ratio should be {numerator:2, denominator:3}
30
- const lyl = `\\time 2/4 \\clef "treble" \\times 2/3 {c8[ d e]}`;
31
- const doc = parseCode(lyl);
32
- const tuplet = doc.measures[0].parts[0].voices[0].events.find(e => e.type === 'tuplet');
33
- assert(tuplet.ratio.numerator === 2 && tuplet.ratio.denominator === 3, "Parser: triplet ratio is 2/3", `Got ${tuplet.ratio.numerator}/${tuplet.ratio.denominator}`);
34
- // Encode to MusicXML - check time-modification
35
- const xml = musicXmlEncoder.encode(doc);
36
- // MusicXML: actual-notes=3 (notes played), normal-notes=2 (normal count)
37
- const hasActual3 = xml.includes('<actual-notes>3</actual-notes>');
38
- const hasNormal2 = xml.includes('<normal-notes>2</normal-notes>');
39
- assert(hasActual3 && hasNormal2, "Encoder: triplet time-modification is actual=3, normal=2", `actual-3=${hasActual3}, normal-2=${hasNormal2}`);
40
- // Decode back - check ratio is preserved
41
- const doc2 = musicXmlDecoder.decode(xml);
42
- const tuplet2 = doc2.measures[0].parts[0].voices[0].events.find(e => e.type === 'tuplet');
43
- assert(tuplet2 !== undefined, "Decoder: triplet event exists after roundtrip");
44
- if (tuplet2) {
45
- assert(tuplet2.ratio.numerator === 2 && tuplet2.ratio.denominator === 3, "Decoder: triplet ratio preserved as 2/3", `Got ${tuplet2.ratio.numerator}/${tuplet2.ratio.denominator}`);
46
- }
47
- }
48
- // Quadruplet: \times 3/4 {c8 d e f}
49
- {
50
- const lyl = `\\time 3/8 \\clef "treble" \\times 3/4 {c8[ d e f]}`;
51
- const doc = parseCode(lyl);
52
- const tuplet = doc.measures[0].parts[0].voices[0].events.find(e => e.type === 'tuplet');
53
- assert(tuplet.ratio.numerator === 3 && tuplet.ratio.denominator === 4, "Parser: quadruplet ratio is 3/4", `Got ${tuplet.ratio.numerator}/${tuplet.ratio.denominator}`);
54
- const xml = musicXmlEncoder.encode(doc);
55
- // MusicXML: actual-notes=4 (notes played), normal-notes=3 (normal count)
56
- const hasActual4 = xml.includes('<actual-notes>4</actual-notes>');
57
- const hasNormal3 = xml.includes('<normal-notes>3</normal-notes>');
58
- assert(hasActual4 && hasNormal3, "Encoder: quadruplet time-modification is actual=4, normal=3", `actual-4=${hasActual4}, normal-3=${hasNormal3}`);
59
- const doc2 = musicXmlDecoder.decode(xml);
60
- const tuplet2 = doc2.measures[0].parts[0].voices[0].events.find(e => e.type === 'tuplet');
61
- if (tuplet2) {
62
- assert(tuplet2.ratio.numerator === 3 && tuplet2.ratio.denominator === 4, "Decoder: quadruplet ratio preserved as 3/4", `Got ${tuplet2.ratio.numerator}/${tuplet2.ratio.denominator}`);
63
- }
64
- }
65
- // ============================================================
66
- // Issue 2: DIVISION_TO_TYPE float key 0.5 (breve)
67
- // GPT says float keys may cause lookup failures
68
- // ============================================================
69
- console.log("\n--- Float Key Lookup (Breve) ---\n");
70
- {
71
- // The parser doesn't support \breve syntax (treats it as multiple tokens).
72
- // Test the DIVISION_TO_TYPE lookup directly instead.
73
- const DIVISION_TO_TYPE = {
74
- 0.5: 'breve', 1: 'whole', 2: 'half', 4: 'quarter', 8: 'eighth',
75
- };
76
- assert(DIVISION_TO_TYPE[0.5] === 'breve', "Float key: DIVISION_TO_TYPE[0.5] works directly", `Got: ${DIVISION_TO_TYPE[0.5]}`);
77
- // Test with computed float value: 4/8 = 0.5 which maps to 'breve'
78
- const computed = 4 / 8; // = 0.5 exactly (power of 2)
79
- assert(DIVISION_TO_TYPE[computed] === 'breve', "Float key: DIVISION_TO_TYPE[4/8=0.5] resolves correctly", `Got: ${DIVISION_TO_TYPE[computed]}`);
80
- console.log(" (Note: breve/\\breve is a parser limitation, not an encoder issue)");
81
- }
82
- // ============================================================
83
- // Issue 3: Cross-staff tuplet in decoder
84
- // GPT says when MusicXML tuplet notes span different staves,
85
- // the decoder loses the staff change information.
86
- // We craft MusicXML directly to test this.
87
- // ============================================================
88
- console.log("\n--- Cross-Staff Tuplet (Decoder) ---\n");
89
- {
90
- // Craft MusicXML with a tuplet where notes are on different staves
91
- // Note 1,2 on staff 2, note 3 on staff 1 (all voice 1)
92
- const xml = `<?xml version="1.0" encoding="UTF-8"?>
93
- <!DOCTYPE score-partwise PUBLIC "-//Recordare//DTD MusicXML 4.0 Partwise//EN" "http://www.musicxml.org/dtds/partwise.dtd">
94
- <score-partwise version="4.0">
95
- <part-list>
96
- <score-part id="P1"><part-name>Piano</part-name></score-part>
97
- </part-list>
98
- <part id="P1">
99
- <measure number="1">
100
- <attributes>
101
- <divisions>4</divisions>
102
- <key><fifths>0</fifths><mode>major</mode></key>
103
- <time><beats>4</beats><beat-type>4</beat-type></time>
104
- <staves>2</staves>
105
- <clef><sign>G</sign><line>2</line></clef>
106
- </attributes>
107
- <note>
108
- <pitch><step>C</step><octave>3</octave></pitch>
109
- <duration>3</duration>
110
- <type>eighth</type>
111
- <time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>
112
- <voice>1</voice>
113
- <staff>2</staff>
114
- <beam number="1">begin</beam>
115
- <notations><tuplet type="start"/></notations>
116
- </note>
117
- <note>
118
- <pitch><step>E</step><octave>3</octave></pitch>
119
- <duration>3</duration>
120
- <type>eighth</type>
121
- <time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>
122
- <voice>1</voice>
123
- <staff>2</staff>
124
- </note>
125
- <note>
126
- <pitch><step>G</step><octave>4</octave></pitch>
127
- <duration>3</duration>
128
- <type>eighth</type>
129
- <time-modification><actual-notes>3</actual-notes><normal-notes>2</normal-notes></time-modification>
130
- <voice>1</voice>
131
- <staff>1</staff>
132
- <beam number="1">end</beam>
133
- <notations><tuplet type="stop"/></notations>
134
- </note>
135
- <note>
136
- <pitch><step>D</step><octave>4</octave></pitch>
137
- <duration>4</duration>
138
- <type>quarter</type>
139
- <voice>1</voice>
140
- <staff>1</staff>
141
- </note>
142
- <note>
143
- <pitch><step>E</step><octave>4</octave></pitch>
144
- <duration>4</duration>
145
- <type>quarter</type>
146
- <voice>1</voice>
147
- <staff>1</staff>
148
- </note>
149
- <note>
150
- <rest measure="yes"/>
151
- <duration>16</duration>
152
- <voice>1</voice>
153
- <staff>1</staff>
154
- </note>
155
- </measure>
156
- </part>
157
- </score-partwise>`;
158
- try {
159
- const doc = musicXmlDecoder.decode(xml);
160
- const voice = doc.measures[0].parts[0].voices[0];
161
- const events = voice.events;
162
- // The voice starts on staff 2 (first tuplet note), so voice.staff should initially be 2
163
- // After the tuplet, notes are on staff 1, so there should be a ContextChange(staff=1) somewhere
164
- console.log(` Voice staff=${voice.staff}, events:`);
165
- for (const e of events) {
166
- if (e.type === 'context')
167
- console.log(` context staff=${e.staff}`);
168
- else if (e.type === 'tuplet') {
169
- const t = e;
170
- console.log(` tuplet ${t.events.length} events, ratio=${t.ratio.numerator}/${t.ratio.denominator}`);
171
- }
172
- else if (e.type === 'note')
173
- console.log(` note ${e.pitches[0].phonet}${e.pitches[0].octave}`);
174
- else if (e.type === 'rest')
175
- console.log(` rest`);
176
- }
177
- // The tuplet stop note is on staff 1, so voiceTracker.addEvent gets staff=1
178
- // This should insert a ContextChange(staff=1) before the tuplet event
179
- const hasStaffChange = events.some(e => e.type === 'context' && e.staff === 1);
180
- assert(hasStaffChange, "Decoder: cross-staff tuplet triggers ContextChange(staff=1)", `Has staff=1 context: ${hasStaffChange}`);
181
- // Check the tuplet itself exists
182
- const tuplet = events.find(e => e.type === 'tuplet');
183
- assert(tuplet !== undefined && tuplet.events.length === 3, "Decoder: cross-staff tuplet has 3 sub-events", tuplet ? `events: ${tuplet.events.length}` : 'no tuplet found');
184
- // The REAL issue: within the tuplet, the first 2 notes are on staff 2
185
- // and the 3rd note is on staff 1. This staff change is LOST inside the tuplet.
186
- // TupletEvent.events is (NoteEvent|RestEvent)[] - no ContextChange possible.
187
- // This is a known architectural limitation.
188
- console.log("\n NOTE: Staff changes WITHIN a tuplet are lost (TupletEvent only allows NoteEvent|RestEvent).");
189
- console.log(" This is an architectural limitation - the type system doesn't support ContextChange inside tuplets.");
190
- }
191
- catch (e) {
192
- assert(false, "Decoder: cross-staff tuplet parsing", `Error: ${e}`);
193
- }
194
- }
195
- // ============================================================
196
- // Issue 4: Encoder encodes twice - verify idempotency with tuplets
197
- // (More targeted than the general encoder-mutation test)
198
- // ============================================================
199
- console.log("\n--- Encoder Idempotency ---\n");
200
- {
201
- const lyl = `\\staff "2" \\key cf \\minor \\time 4/4 \\clef "treble" \\times 2/3 {c8[ ( e a]} \\staff "1" \\times 2/3 {c[ e f]} \\times 2/3 {g[ d b]} \\staff "2" \\times 2/3 {g[ d b] )} |`;
202
- const doc = parseCode(lyl);
203
- const xml1 = musicXmlEncoder.encode(doc);
204
- const xml2 = musicXmlEncoder.encode(doc);
205
- assert(xml1 === xml2, "Encoder: double-encode produces identical output (cross-staff tuplets)");
206
- // Also check that the AST tuplet durations aren't mutated
207
- for (const m of doc.measures) {
208
- for (const p of m.parts) {
209
- for (const v of p.voices) {
210
- for (const e of v.events) {
211
- if (e.type === 'tuplet') {
212
- for (const sub of e.events) {
213
- if (sub.duration.tuplet !== undefined) {
214
- assert(false, "Encoder: tuplet sub-event duration.tuplet not restored", `Found lingering tuplet: ${JSON.stringify(sub.duration.tuplet)}`);
215
- }
216
- }
217
- }
218
- }
219
- }
220
- }
221
- }
222
- assert(true, "Encoder: no lingering duration.tuplet on sub-events after encode");
223
- }
224
- // ============================================================
225
- // Issue 5: Duration calculation accuracy (Math.round)
226
- // Test that a full measure of tuplets adds up correctly
227
- // ============================================================
228
- console.log("\n--- Duration Accuracy ---\n");
229
- {
230
- // 4/4 time, four triplets = 4 beats
231
- const lyl = `\\time 4/4 \\clef "treble" \\times 2/3 {c8[ d e]} \\times 2/3 {f[ g a]} \\times 2/3 {b[ c' d']} \\times 2/3 {e'[ f' g']}`;
232
- const doc = parseCode(lyl);
233
- const xml = musicXmlEncoder.encode(doc);
234
- // With DIVISIONS=4, triplet 8th = round(4 * 0.5 * 2/3) = round(1.33) = 1
235
- // 12 notes × 1 = 12. Ideal would be 16 (4 beats × 4 divisions).
236
- // This rounding is a known limitation of DIVISIONS=4 for tuplets.
237
- // Test that each triplet group sums consistently (all notes same duration)
238
- const durationMatches = xml.match(/<duration>(\d+)<\/duration>/g);
239
- if (durationMatches) {
240
- const durations = durationMatches.map(m => parseInt(m.replace(/<\/?duration>/g, '')));
241
- const allSame = durations.every(d => d === durations[0]);
242
- assert(allSame && durations[0] === 1, "Duration: triplet 8th notes each round to 1 division (DIVISIONS=4 limitation)", `individual: [${durations.join(', ')}]`);
243
- // Note: ideal total would be 16, actual is 12 due to rounding with small DIVISIONS
244
- console.log(` (Total: ${durations.reduce((a, b) => a + b, 0)}, ideal: 16 — rounding artifact with DIVISIONS=4)`);
245
- }
246
- else {
247
- assert(false, "Duration: no duration elements found");
248
- }
249
- }
250
- // ============================================================
251
- // Summary
252
- // ============================================================
253
- console.log("\n" + "=".repeat(80));
254
- console.log(`\nResults: ${passed} passed, ${failed} failed`);
255
- process.exit(failed > 0 ? 1 : 0);
@@ -1 +0,0 @@
1
- export {};
@@ -1,18 +0,0 @@
1
- import { serializeLilyletDoc } from '../source/lilylet/index.js';
2
- import * as fs from 'fs';
3
- const files = [
4
- 'rest-full-42',
5
- 'rest-full-44',
6
- 'chords-simple-triad-chord-c-e-g',
7
- 'multiple-staves-3voices',
8
- 'multiple-voices-two-voices-with-vb-separator',
9
- ];
10
- for (const name of files) {
11
- const doc = JSON.parse(fs.readFileSync(`tests/output/lilypond-roundtrip/${name}.json`, 'utf-8'));
12
- console.log(`\n=== ${name} ===`);
13
- console.log(serializeLilyletDoc(doc));
14
- }
15
- // cross-staves2
16
- const doc2 = JSON.parse(fs.readFileSync('tests/output/lilypond-roundtrip/multiple-staves-cross-staves2.json', 'utf-8'));
17
- console.log('\n=== multiple-staves-cross-staves2 ===');
18
- console.log(serializeLilyletDoc(doc2));
@@ -1,7 +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
- export {};