@k-l-lambda/lilylet 0.1.71 → 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.
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,1035 +0,0 @@
1
- /**
2
- * ABC Notation Decoder for Lilylet
3
- *
4
- * Converts ABC notation files to Lilylet's internal LilyletDoc format.
5
- */
6
- import parse from "../abc/parser.js";
7
- import { Phonet, Accidental, Clef, ArticulationType, OrnamentType, DynamicType, HairpinType, PedalType, NavigationMarkType, } from "./types.js";
8
- // ============ Constants ============
9
- const ABC_PHONET_MAP = {
10
- "C": Phonet.c, "D": Phonet.d, "E": Phonet.e, "F": Phonet.f, "G": Phonet.g, "A": Phonet.a, "B": Phonet.b,
11
- "c": Phonet.c, "d": Phonet.d, "e": Phonet.e, "f": Phonet.f, "g": Phonet.g, "a": Phonet.a, "b": Phonet.b,
12
- };
13
- const ABC_KEY_MAP = {
14
- "C": { pitch: Phonet.c },
15
- "G": { pitch: Phonet.g },
16
- "D": { pitch: Phonet.d },
17
- "A": { pitch: Phonet.a },
18
- "E": { pitch: Phonet.e },
19
- "B": { pitch: Phonet.b },
20
- "F": { pitch: Phonet.f },
21
- "Cb": { pitch: Phonet.c, accidental: Accidental.flat },
22
- "Gb": { pitch: Phonet.g, accidental: Accidental.flat },
23
- "Db": { pitch: Phonet.d, accidental: Accidental.flat },
24
- "Ab": { pitch: Phonet.a, accidental: Accidental.flat },
25
- "Eb": { pitch: Phonet.e, accidental: Accidental.flat },
26
- "Bb": { pitch: Phonet.b, accidental: Accidental.flat },
27
- "F#": { pitch: Phonet.f, accidental: Accidental.sharp },
28
- "C#": { pitch: Phonet.c, accidental: Accidental.sharp },
29
- "G#": { pitch: Phonet.g, accidental: Accidental.sharp },
30
- "D#": { pitch: Phonet.d, accidental: Accidental.sharp },
31
- "A#": { pitch: Phonet.a, accidental: Accidental.sharp },
32
- "E#": { pitch: Phonet.e, accidental: Accidental.sharp },
33
- "B#": { pitch: Phonet.b, accidental: Accidental.sharp },
34
- };
35
- const DYNAMIC_MAP = {
36
- "ppp": DynamicType.ppp,
37
- "pp": DynamicType.pp,
38
- "p": DynamicType.p,
39
- "mp": DynamicType.mp,
40
- "mf": DynamicType.mf,
41
- "f": DynamicType.f,
42
- "ff": DynamicType.ff,
43
- "fff": DynamicType.fff,
44
- "sfz": DynamicType.sfz,
45
- };
46
- // ============ Utility Functions ============
47
- /**
48
- * Convert ABC accidental to Lilylet Accidental
49
- */
50
- const convertAccidental = (acc) => {
51
- if (acc === null || acc === undefined)
52
- return undefined;
53
- switch (acc) {
54
- case -2: return Accidental.doubleFlat;
55
- case -1: return Accidental.flat;
56
- case 0: return undefined; // natural: omit since lilylet parser doesn't support '!'
57
- case 1: return Accidental.sharp;
58
- case 2: return Accidental.doubleSharp;
59
- default: return undefined;
60
- }
61
- };
62
- /**
63
- * Convert ABC pitch to Lilylet Pitch
64
- * Uppercase C-B = octave 0, lowercase c-b = octave 1
65
- * quotes (from ' and ,) add/subtract octaves
66
- */
67
- const convertPitch = (abcPitch) => {
68
- const phonet = ABC_PHONET_MAP[abcPitch.phonet];
69
- if (!phonet) {
70
- throw new Error(`Unknown ABC phonet: ${abcPitch.phonet}`);
71
- }
72
- // Uppercase = octave 0 (middle C octave), lowercase = octave 1
73
- const isLower = abcPitch.phonet >= "a" && abcPitch.phonet <= "g";
74
- const baseOctave = isLower ? 1 : 0;
75
- const octave = baseOctave + (abcPitch.quotes || 0);
76
- const pitch = { phonet, octave };
77
- const accidental = convertAccidental(abcPitch.acc);
78
- if (accidental) {
79
- pitch.accidental = accidental;
80
- }
81
- return pitch;
82
- };
83
- /**
84
- * Convert ABC duration fraction to Lilylet Duration.
85
- * ABC durations are multipliers of the unit length (L: field).
86
- *
87
- * actualLength = unitLength * (numerator / denominator)
88
- * Then convert fraction-of-whole-note to {division, dots}
89
- */
90
- const convertDuration = (abcDuration, unitLength) => {
91
- const num = abcDuration?.numerator ?? 1;
92
- const den = abcDuration?.denominator ?? 1;
93
- // actualLength as fraction of whole note: unitLength * duration
94
- const actualNum = unitLength.numerator * num;
95
- const actualDen = unitLength.denominator * den;
96
- // Try to match {division, dots} where:
97
- // division=d, dots=0: duration = 1/d
98
- // division=d, dots=1: duration = 1/d * 1.5 = 3/(2d)
99
- // division=d, dots=2: duration = 1/d * 1.75 = 7/(4d)
100
- for (const dots of [0, 1, 2]) {
101
- let testNum;
102
- let testDen;
103
- if (dots === 0) {
104
- testNum = 1;
105
- testDen = 1; // 1/division
106
- }
107
- else if (dots === 1) {
108
- testNum = 3;
109
- testDen = 2; // 3/(2*division)
110
- }
111
- else {
112
- testNum = 7;
113
- testDen = 4; // 7/(4*division)
114
- }
115
- // We need: actualNum/actualDen = testNum / (testDen * division)
116
- // So: division = testNum * actualDen / (testDen * actualNum)
117
- const divNum = testNum * actualDen;
118
- const divDen = testDen * actualNum;
119
- if (divDen > 0 && divNum % divDen === 0) {
120
- const division = divNum / divDen;
121
- // Check it's a valid power of 2
122
- if (division > 0 && (division & (division - 1)) === 0) {
123
- return { division, dots };
124
- }
125
- }
126
- }
127
- // Fallback: find closest power-of-2 division
128
- const ratio = actualNum / actualDen;
129
- const division = Math.max(1, Math.round(1 / ratio));
130
- // Snap to nearest power of 2
131
- const log2 = Math.round(Math.log2(division));
132
- return { division: Math.pow(2, Math.max(0, log2)), dots: 0 };
133
- };
134
- /**
135
- * Apply broken rhythm adjustment.
136
- * broken > 0 (A>B): current note gets dotted, next gets halved
137
- * broken < 0 (A<B): current note gets halved, next gets dotted
138
- */
139
- const applyBrokenRhythm = (events, brokenIndex, broken) => {
140
- if (brokenIndex < 0 || brokenIndex >= events.length - 1)
141
- return;
142
- const abs = Math.abs(broken);
143
- const multiplier = Math.pow(2, abs);
144
- const current = events[brokenIndex];
145
- const next = events[brokenIndex + 1];
146
- if (broken > 0) {
147
- // Current gets longer (multiply by 2-1/multiplier), next gets shorter
148
- // A>B: A is dotted (3/2), B is halved (1/2)
149
- // A>>B: A gets 7/4, B gets 1/4
150
- adjustDurationMultiply(current.duration, (2 * multiplier - 1), multiplier);
151
- adjustDurationMultiply(next.duration, 1, multiplier);
152
- }
153
- else {
154
- adjustDurationMultiply(current.duration, 1, multiplier);
155
- adjustDurationMultiply(next.duration, (2 * multiplier - 1), multiplier);
156
- }
157
- };
158
- /**
159
- * Multiply a duration by num/den and re-derive division+dots
160
- */
161
- const adjustDurationMultiply = (dur, num, den) => {
162
- // Current value as fraction of whole note
163
- let valueNum;
164
- let valueDen;
165
- if (dur.dots === 0) {
166
- valueNum = 1;
167
- valueDen = dur.division;
168
- }
169
- else if (dur.dots === 1) {
170
- valueNum = 3;
171
- valueDen = 2 * dur.division;
172
- }
173
- else {
174
- valueNum = 7;
175
- valueDen = 4 * dur.division;
176
- }
177
- const newNum = valueNum * num;
178
- const newDen = valueDen * den;
179
- // Re-derive division+dots from the new fraction
180
- const result = fractionToDivisionDots(newNum, newDen);
181
- dur.division = result.division;
182
- dur.dots = result.dots;
183
- };
184
- const fractionToDivisionDots = (num, den) => {
185
- for (const dots of [0, 1, 2]) {
186
- let testNum;
187
- let testDen;
188
- if (dots === 0) {
189
- testNum = 1;
190
- testDen = 1;
191
- }
192
- else if (dots === 1) {
193
- testNum = 3;
194
- testDen = 2;
195
- }
196
- else {
197
- testNum = 7;
198
- testDen = 4;
199
- }
200
- const divNum = testNum * den;
201
- const divDen = testDen * num;
202
- if (divDen > 0 && divNum % divDen === 0) {
203
- const division = divNum / divDen;
204
- if (division > 0 && (division & (division - 1)) === 0) {
205
- return { division, dots };
206
- }
207
- }
208
- }
209
- const ratio = num / den;
210
- const division = Math.max(1, Math.round(1 / ratio));
211
- const log2 = Math.round(Math.log2(division));
212
- return { division: Math.pow(2, Math.max(0, log2)), dots: 0 };
213
- };
214
- /**
215
- * Convert ABC key signature to Lilylet KeySignature
216
- */
217
- const convertKeySignature = (abcKey) => {
218
- const keyEntry = ABC_KEY_MAP[abcKey.root];
219
- if (!keyEntry) {
220
- // Try parsing root + accidental from string
221
- const root = abcKey.root.charAt(0);
222
- const acc = abcKey.root.substring(1);
223
- const entry = ABC_KEY_MAP[root] || { pitch: Phonet.c };
224
- return {
225
- pitch: entry.pitch,
226
- accidental: acc === "b" ? Accidental.flat : acc === "#" ? Accidental.sharp : entry.accidental,
227
- mode: (abcKey.mode === "minor" || abcKey.mode === "min") ? "minor" : "major",
228
- };
229
- }
230
- return {
231
- pitch: keyEntry.pitch,
232
- accidental: keyEntry.accidental,
233
- mode: (abcKey.mode === "minor" || abcKey.mode === "min") ? "minor" : "major",
234
- };
235
- };
236
- /**
237
- * Convert ABC clef string to Lilylet Clef
238
- */
239
- const convertClef = (clefStr) => {
240
- switch (clefStr?.toLowerCase()) {
241
- case "treble": return Clef.treble;
242
- case "bass": return Clef.bass;
243
- case "alto":
244
- case "tenor": return Clef.alto;
245
- default: return undefined;
246
- }
247
- };
248
- /**
249
- * Convert ABC barline to Lilylet barline style
250
- */
251
- const convertBarline = (bar) => {
252
- if (!bar)
253
- return undefined;
254
- switch (bar) {
255
- case "|": return "|";
256
- case "||": return "||";
257
- case "|]": return "|.";
258
- case "|:": return ".|:";
259
- case ":|": return ":|.";
260
- case ":|:":
261
- case ":||:": return ":..:";
262
- default:
263
- if (bar.startsWith(":|"))
264
- return ":|.";
265
- if (bar.startsWith("|:"))
266
- return ".|:";
267
- return "|";
268
- }
269
- };
270
- /**
271
- * Parse %%score layout to determine voice→(part, staff) mapping.
272
- * {(...) | (...)} = one part with two staves
273
- * (...) = voices sharing one staff
274
- */
275
- const parseScoreLayout = (headers) => {
276
- const layoutHeader = headers.find((h) => h.staffLayout);
277
- if (!layoutHeader)
278
- return null;
279
- const layout = layoutHeader.staffLayout;
280
- const voiceMap = new Map();
281
- let partIndex = 0;
282
- for (const group of layout) {
283
- if (group.bound === "curly") {
284
- // Curly braces = one instrument/part with multiple staves
285
- let staffInPart = 1;
286
- for (const item of group.items) {
287
- if (typeof item === "string") {
288
- voiceMap.set(parseInt(item), { partIndex, staffInPart });
289
- }
290
- else if (item.items) {
291
- const sg = item;
292
- for (const subItem of sg.items) {
293
- if (typeof subItem === "string") {
294
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
295
- }
296
- else if (subItem.items) {
297
- for (const leaf of subItem.items) {
298
- if (typeof leaf === "string") {
299
- voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
300
- }
301
- }
302
- }
303
- }
304
- staffInPart++;
305
- }
306
- }
307
- partIndex++;
308
- }
309
- else if (group.bound === "arc" || !group.bound) {
310
- // Arc or plain = voices sharing a staff in same part
311
- for (const item of group.items) {
312
- if (typeof item === "string") {
313
- voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
314
- }
315
- else if (item.items) {
316
- for (const subItem of item.items) {
317
- if (typeof subItem === "string") {
318
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
319
- }
320
- }
321
- }
322
- }
323
- partIndex++;
324
- }
325
- else {
326
- // Square bracket or unknown - treat each item as separate part
327
- for (const item of group.items) {
328
- if (typeof item === "string") {
329
- voiceMap.set(parseInt(item), { partIndex, staffInPart: 1 });
330
- partIndex++;
331
- }
332
- else if (item.items) {
333
- const sg = item;
334
- if (sg.bound === "curly") {
335
- let staffInPart = 1;
336
- for (const subItem of sg.items) {
337
- if (typeof subItem === "string") {
338
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart });
339
- }
340
- else if (subItem.items) {
341
- for (const leaf of subItem.items) {
342
- if (typeof leaf === "string") {
343
- voiceMap.set(parseInt(leaf), { partIndex, staffInPart });
344
- }
345
- }
346
- staffInPart++;
347
- }
348
- }
349
- partIndex++;
350
- }
351
- else {
352
- for (const subItem of sg.items) {
353
- if (typeof subItem === "string") {
354
- voiceMap.set(parseInt(subItem), { partIndex, staffInPart: 1 });
355
- }
356
- }
357
- partIndex++;
358
- }
359
- }
360
- }
361
- }
362
- }
363
- return voiceMap.size > 0 ? voiceMap : null;
364
- };
365
- // ============ Marks/Decorations Conversion ============
366
- const convertArticulationMark = (artName) => {
367
- switch (artName) {
368
- case "accent":
369
- case "L":
370
- return { markType: "articulation", type: ArticulationType.accent };
371
- case "staccato":
372
- return { markType: "articulation", type: ArticulationType.staccato };
373
- case "tenuto":
374
- return { markType: "articulation", type: ArticulationType.tenuto };
375
- case "marcato":
376
- return { markType: "articulation", type: ArticulationType.marcato };
377
- case "emphasis":
378
- return { markType: "articulation", type: ArticulationType.accent };
379
- case "trill":
380
- case "T":
381
- return { markType: "ornament", type: OrnamentType.trill };
382
- case "mordent":
383
- case "M":
384
- return { markType: "ornament", type: OrnamentType.mordent };
385
- case "prall":
386
- case "P":
387
- return { markType: "ornament", type: OrnamentType.prall };
388
- case "turn":
389
- return { markType: "ornament", type: OrnamentType.turn };
390
- case "fermata":
391
- case "H":
392
- return { markType: "ornament", type: OrnamentType.fermata };
393
- case "roll":
394
- case "R":
395
- return { markType: "ornament", type: OrnamentType.arpeggio };
396
- case "arpeggio":
397
- return { markType: "ornament", type: OrnamentType.arpeggio };
398
- default: return undefined;
399
- }
400
- };
401
- /**
402
- * Process an ABC expressive/articulation term into marks
403
- */
404
- const processExpressiveTerm = (term, pendingMarks, pendingContextChanges, slurDepth) => {
405
- if (term.express) {
406
- const expr = term.express;
407
- if (expr === "(") {
408
- pendingMarks.push({ markType: "slur", start: true });
409
- slurDepth.count++;
410
- }
411
- else if (expr === ")") {
412
- if (slurDepth.count > 0) {
413
- pendingMarks.push({ markType: "slur", start: false });
414
- slurDepth.count--;
415
- }
416
- }
417
- else if (expr === ".") {
418
- pendingMarks.push({ markType: "articulation", type: ArticulationType.staccato });
419
- }
420
- else if (expr === "-") {
421
- pendingMarks.push({ markType: "tie", start: true });
422
- }
423
- else if (expr === "coda") {
424
- pendingMarks.push({ markType: "navigation", type: NavigationMarkType.coda });
425
- }
426
- else if (expr === "segno") {
427
- pendingMarks.push({ markType: "navigation", type: NavigationMarkType.segno });
428
- }
429
- }
430
- else if (term.articulation !== undefined) {
431
- const artContent = term.articulation;
432
- const scope = term.scope;
433
- // Hairpins
434
- if (artContent === "<") {
435
- if (scope === "(") {
436
- pendingMarks.push({ markType: "hairpin", type: HairpinType.crescendoStart });
437
- }
438
- else if (scope === ")") {
439
- pendingMarks.push({ markType: "hairpin", type: HairpinType.crescendoEnd });
440
- }
441
- else {
442
- pendingMarks.push({ markType: "hairpin", type: HairpinType.crescendoStart });
443
- }
444
- }
445
- else if (artContent === ">") {
446
- if (scope === "(") {
447
- pendingMarks.push({ markType: "hairpin", type: HairpinType.diminuendoStart });
448
- }
449
- else if (scope === ")") {
450
- pendingMarks.push({ markType: "hairpin", type: HairpinType.diminuendoEnd });
451
- }
452
- else {
453
- pendingMarks.push({ markType: "articulation", type: ArticulationType.accent });
454
- }
455
- }
456
- // Dynamics
457
- else if (DYNAMIC_MAP[artContent]) {
458
- pendingMarks.push({ markType: "dynamic", type: DYNAMIC_MAP[artContent] });
459
- }
460
- // Pedal
461
- else if (artContent === "ped") {
462
- pendingMarks.push({ markType: "pedal", type: PedalType.sustainOn });
463
- }
464
- else if (artContent === "ped-up") {
465
- pendingMarks.push({ markType: "pedal", type: PedalType.sustainOff });
466
- }
467
- // Named articulations/ornaments
468
- else {
469
- const mark = convertArticulationMark(artContent);
470
- if (mark) {
471
- pendingMarks.push(mark);
472
- }
473
- }
474
- }
475
- else if (term.fingering !== undefined) {
476
- const finger = typeof term.fingering === "string" ? parseInt(term.fingering) : term.fingering;
477
- if (finger >= 1 && finger <= 5) {
478
- pendingMarks.push({ markType: "fingering", finger });
479
- }
480
- }
481
- else if (term.octaveShift !== undefined) {
482
- pendingContextChanges.push({
483
- type: "context",
484
- ottava: -term.octaveShift, // ABC: positive=shift down, Lilylet: positive=shift up
485
- });
486
- }
487
- else if (term.tremolo !== undefined) {
488
- // Tremolo marks are handled on notes directly
489
- }
490
- };
491
- /**
492
- * Process a single ABC BarPatch (one voice's content for one measure) into events.
493
- */
494
- const processBarPatch = (patch, unitLength, slurDepth) => {
495
- const events = [];
496
- const terms = patch.terms || [];
497
- const pendingMarks = [];
498
- const pendingContextChanges = [];
499
- // Collect all events first, then handle broken rhythms and tuplets
500
- const rawNoteRests = [];
501
- let i = 0;
502
- while (i < terms.length) {
503
- const term = terms[i];
504
- // Control (inline field like [K:G])
505
- if (term.control) {
506
- const ctrl = term.control;
507
- if (ctrl.name === "K") {
508
- if (ctrl.value?.clef) {
509
- const clef = convertClef(ctrl.value.clef);
510
- if (clef) {
511
- events.push({ type: "context", clef });
512
- }
513
- }
514
- else if (ctrl.value?.root) {
515
- events.push({
516
- type: "context",
517
- key: convertKeySignature(ctrl.value),
518
- });
519
- }
520
- }
521
- else if (ctrl.name === "M") {
522
- if (ctrl.value?.numerator && ctrl.value?.denominator) {
523
- events.push({
524
- type: "context",
525
- time: { numerator: ctrl.value.numerator, denominator: ctrl.value.denominator },
526
- });
527
- }
528
- }
529
- else if (ctrl.name === "Q") {
530
- if (ctrl.value?.note && ctrl.value?.bpm) {
531
- const beatDuration = convertDuration(ctrl.value.note, { numerator: 1, denominator: 1 });
532
- events.push({
533
- type: "context",
534
- tempo: { beat: beatDuration, bpm: ctrl.value.bpm },
535
- });
536
- }
537
- }
538
- else if (ctrl.name === "V") {
539
- // Voice change within measure - skip (handled at measure level)
540
- }
541
- i++;
542
- continue;
543
- }
544
- // Tuplet marker
545
- if (term.triplet !== undefined) {
546
- const tripletTerm = term;
547
- const p = tripletTerm.triplet; // number of notes in group
548
- const q = tripletTerm.multiplier ?? getDefaultTupletMultiplier(p); // notes in time of q
549
- const r = tripletTerm.n ?? p; // applies to r notes
550
- // Collect next r note/rest events
551
- const tupletEvents = [];
552
- let j = i + 1;
553
- let collected = 0;
554
- while (j < terms.length && collected < r) {
555
- const nextTerm = terms[j];
556
- if (nextTerm.event) {
557
- const evt = convertEventTerm(nextTerm, unitLength, pendingMarks, pendingContextChanges);
558
- if (evt) {
559
- // Push any pending context changes before tuplet
560
- for (const ctx of pendingContextChanges.splice(0)) {
561
- events.push(ctx);
562
- }
563
- if (Array.isArray(evt)) {
564
- for (const e of evt) {
565
- if (e.type === "note" || e.type === "rest") {
566
- tupletEvents.push(e);
567
- collected++;
568
- }
569
- else {
570
- events.push(e);
571
- }
572
- }
573
- }
574
- else if (evt.type === "note" || evt.type === "rest") {
575
- tupletEvents.push(evt);
576
- collected++;
577
- }
578
- }
579
- }
580
- else if (isExpressiveTerm(nextTerm)) {
581
- processExpressiveTerm(nextTerm, pendingMarks, pendingContextChanges, slurDepth);
582
- }
583
- else if (nextTerm.grace) {
584
- const graceEvents = convertGraceEvents(nextTerm, unitLength);
585
- events.push(...graceEvents);
586
- }
587
- j++;
588
- }
589
- if (tupletEvents.length > 0) {
590
- // Lilylet ratio: {num: q, den: p} means "q in time of p"
591
- events.push({
592
- type: "tuplet",
593
- ratio: { numerator: q, denominator: p },
594
- events: tupletEvents,
595
- });
596
- }
597
- i = j;
598
- continue;
599
- }
600
- // Grace notes
601
- if (term.grace) {
602
- const graceEvents = convertGraceEvents(term, unitLength);
603
- events.push(...graceEvents);
604
- i++;
605
- continue;
606
- }
607
- // Expressive marks
608
- if (isExpressiveTerm(term)) {
609
- processExpressiveTerm(term, pendingMarks, pendingContextChanges, slurDepth);
610
- i++;
611
- continue;
612
- }
613
- // Text
614
- if (term.text !== undefined) {
615
- const text = term.text;
616
- // Check if it's a tempo/expression marking
617
- if (text.startsWith("^")) {
618
- // Markup text above staff
619
- }
620
- i++;
621
- continue;
622
- }
623
- // Event (note/rest)
624
- if (term.event) {
625
- const eventTerm = term;
626
- // Push pending context changes
627
- for (const ctx of pendingContextChanges.splice(0)) {
628
- events.push(ctx);
629
- }
630
- const evt = convertEventTerm(eventTerm, unitLength, pendingMarks, pendingContextChanges);
631
- if (evt) {
632
- if (Array.isArray(evt)) {
633
- events.push(...evt);
634
- }
635
- else {
636
- events.push(evt);
637
- }
638
- // Track broken rhythm
639
- if (eventTerm.broken) {
640
- const noteRestEvents = events.filter(e => e.type === "note" || e.type === "rest");
641
- if (noteRestEvents.length >= 2) {
642
- applyBrokenRhythm(noteRestEvents, noteRestEvents.length - 2, eventTerm.broken);
643
- }
644
- }
645
- }
646
- i++;
647
- continue;
648
- }
649
- i++;
650
- }
651
- const barline = convertBarline(patch.bar);
652
- return { events, barline };
653
- };
654
- /**
655
- * Check if a term is an expressive mark
656
- */
657
- const isExpressiveTerm = (term) => {
658
- return term.express !== undefined ||
659
- term.articulation !== undefined ||
660
- term.fingering !== undefined ||
661
- term.octaveShift !== undefined ||
662
- term.tremolo !== undefined;
663
- };
664
- /**
665
- * Get default tuplet multiplier based on ABC convention
666
- */
667
- const getDefaultTupletMultiplier = (p) => {
668
- // In compound time (6/8, 9/8, 12/8), the defaults differ
669
- // For simplicity, use standard defaults:
670
- if (p === 2)
671
- return 3; // duplet: 2 in time of 3
672
- if (p === 3)
673
- return 2; // triplet: 3 in time of 2
674
- if (p === 4)
675
- return 3; // quadruplet: 4 in time of 3
676
- if (p === 5)
677
- return 2; // 5 in time of 2 (or 4/6)
678
- if (p === 6)
679
- return 2; // sextuplet
680
- if (p === 7)
681
- return 2; // 7 in time of 4
682
- if (p === 9)
683
- return 2; // 9 in time of 8
684
- return 2; // default
685
- };
686
- /**
687
- * Convert a single ABC EventTerm to Lilylet event(s)
688
- */
689
- const convertEventTerm = (eventTerm, unitLength, pendingMarks, pendingContextChanges) => {
690
- const eventData = eventTerm.event;
691
- if (!eventData)
692
- return undefined;
693
- const chord = eventData.chord;
694
- if (!chord || !chord.pitches || chord.pitches.length === 0)
695
- return undefined;
696
- const firstPitch = chord.pitches[0];
697
- // Check if rest
698
- if (firstPitch.phonet === "z" || firstPitch.phonet === "Z" || firstPitch.phonet === "x" || firstPitch.phonet === "y") {
699
- const duration = convertDuration(eventData.duration, unitLength);
700
- const rest = {
701
- type: "rest",
702
- duration,
703
- };
704
- if (firstPitch.phonet === "x" || firstPitch.phonet === "y") {
705
- rest.invisible = true;
706
- }
707
- if (firstPitch.phonet === "Z") {
708
- rest.fullMeasure = true;
709
- }
710
- // Consume pending marks (attach to rest if any)
711
- pendingMarks.length = 0;
712
- return rest;
713
- }
714
- // Note or chord
715
- const pitches = chord.pitches.filter(p => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x" && p.phonet !== "y").map(convertPitch);
716
- if (pitches.length === 0)
717
- return undefined;
718
- const duration = convertDuration(eventData.duration, unitLength);
719
- const marks = [...pendingMarks];
720
- pendingMarks.length = 0;
721
- // Handle tie
722
- const hasTie = chord.pitches.some(p => p.tie);
723
- if (hasTie) {
724
- marks.push({ markType: "tie", start: true });
725
- }
726
- const note = {
727
- type: "note",
728
- pitches,
729
- duration,
730
- };
731
- if (marks.length > 0) {
732
- note.marks = marks;
733
- }
734
- // Push pending context changes before note
735
- if (pendingContextChanges.length > 0) {
736
- const result = [...pendingContextChanges.splice(0), note];
737
- return result;
738
- }
739
- return note;
740
- };
741
- /**
742
- * Convert grace notes to NoteEvents with grace flag
743
- */
744
- const convertGraceEvents = (graceTerm, unitLength) => {
745
- const events = [];
746
- if (!graceTerm.events)
747
- return events;
748
- for (const item of graceTerm.events) {
749
- if (item.event) {
750
- const eventData = item.event;
751
- const chord = eventData.chord;
752
- if (!chord || !chord.pitches)
753
- continue;
754
- const pitches = chord.pitches.filter((p) => p.phonet !== "z" && p.phonet !== "Z" && p.phonet !== "x").map(convertPitch);
755
- if (pitches.length === 0)
756
- continue;
757
- const duration = convertDuration(eventData.duration, unitLength);
758
- const note = {
759
- type: "note",
760
- pitches,
761
- duration,
762
- grace: true,
763
- };
764
- events.push(note);
765
- }
766
- }
767
- return events;
768
- };
769
- // ============ Main Decoder ============
770
- /**
771
- * Decode an ABC tune into a LilyletDoc
772
- */
773
- const decodeTune = (tune) => {
774
- const headers = tune.header;
775
- const body = tune.body;
776
- // Extract header fields
777
- const metadata = {};
778
- let unitLength = { numerator: 1, denominator: 8 }; // Default L:1/8
779
- let timeSig;
780
- let keySig;
781
- let tempo;
782
- const voiceConfigs = new Map();
783
- const voiceClefs = new Map();
784
- // Pre-scan for unit length (needed for bare Q: tempo)
785
- for (const h of headers) {
786
- const hdr = h;
787
- if (hdr.name === "L" && hdr.value?.numerator && hdr.value?.denominator) {
788
- unitLength = hdr.value;
789
- break;
790
- }
791
- }
792
- for (const h of headers) {
793
- if (h.comment)
794
- continue;
795
- if (h.staffLayout)
796
- continue;
797
- const header = h;
798
- switch (header.name) {
799
- case "T":
800
- if (!metadata.title)
801
- metadata.title = header.value;
802
- break;
803
- case "C":
804
- metadata.composer = header.value;
805
- break;
806
- case "L":
807
- if (header.value?.numerator && header.value?.denominator) {
808
- unitLength = header.value;
809
- }
810
- break;
811
- case "M":
812
- if (header.value?.numerator && header.value?.denominator) {
813
- timeSig = {
814
- numerator: header.value.numerator,
815
- denominator: header.value.denominator,
816
- };
817
- }
818
- break;
819
- case "K":
820
- if (header.value?.root) {
821
- keySig = convertKeySignature(header.value);
822
- }
823
- else if (header.value?.clef) {
824
- // Key header with clef only
825
- }
826
- break;
827
- case "Q":
828
- if (header.value?.note && header.value?.bpm) {
829
- const beatDuration = convertDuration(header.value.note, { numerator: 1, denominator: 1 });
830
- tempo = { beat: beatDuration, bpm: header.value.bpm };
831
- }
832
- else if (typeof header.value === "number") {
833
- const beat = convertDuration({ numerator: 1, denominator: 1 }, unitLength);
834
- tempo = { beat, bpm: header.value };
835
- }
836
- break;
837
- case "V": {
838
- const voiceValue = header.value;
839
- if (voiceValue) {
840
- let voiceId;
841
- let clefStr;
842
- if (typeof voiceValue === "number") {
843
- voiceId = voiceValue;
844
- }
845
- else if (typeof voiceValue === "string") {
846
- voiceId = voiceValue;
847
- }
848
- else {
849
- const rawClef = (voiceValue.clef || "").replace(/,+$/, "").trim();
850
- const isKnownClef = !!convertClef(rawClef);
851
- if (isKnownClef) {
852
- // V:1 treble → voiceId=number, clef=treble
853
- voiceId = voiceValue.name || 1;
854
- clefStr = rawClef;
855
- }
856
- else {
857
- // V:S clef=treble → voiceId=voiceName, clef from properties
858
- voiceId = rawClef || voiceValue.name || 1;
859
- const propClef = (voiceValue.properties?.clef || "").replace(/,+$/, "").trim();
860
- clefStr = propClef || undefined;
861
- }
862
- }
863
- voiceConfigs.set(voiceId, {
864
- name: typeof voiceId === "number" ? voiceId : 1,
865
- clef: clefStr,
866
- properties: voiceValue?.properties,
867
- });
868
- if (clefStr) {
869
- const clef = convertClef(clefStr);
870
- if (clef)
871
- voiceClefs.set(voiceId, clef);
872
- }
873
- }
874
- break;
875
- }
876
- }
877
- }
878
- // Parse score layout
879
- const scoreLayout = parseScoreLayout(headers);
880
- // Group measures by voice
881
- // ABC measures contain BarPatches, each with a voice control V:n
882
- const measures = body.measures;
883
- const voiceSlurDepths = new Map();
884
- // Process each ABC measure into Lilylet Measure
885
- const lilyletMeasures = [];
886
- for (let mi = 0; mi < measures.length; mi++) {
887
- const abcMeasure = measures[mi];
888
- // Group patches by voice number
889
- const voicePatches = new Map();
890
- for (const patch of abcMeasure.voices) {
891
- const voiceNum = patch.control?.V || 1;
892
- if (!voicePatches.has(voiceNum)) {
893
- voicePatches.set(voiceNum, []);
894
- }
895
- voicePatches.get(voiceNum).push(patch);
896
- }
897
- // Process each voice
898
- const partVoicesMap = new Map(); // partIndex → (staffNum → voices)
899
- for (const [voiceNum, patches] of voicePatches) {
900
- const slurDepth = voiceSlurDepths.get(voiceNum) || { count: 0 };
901
- voiceSlurDepths.set(voiceNum, slurDepth);
902
- // Merge all patches for this voice in this measure
903
- const allEvents = [];
904
- let barline;
905
- for (const patch of patches) {
906
- const result = processBarPatch(patch, unitLength, slurDepth);
907
- allEvents.push(...result.events);
908
- if (result.barline && result.barline !== "|") {
909
- barline = result.barline;
910
- }
911
- }
912
- if (barline) {
913
- allEvents.push({ type: "barline", style: barline });
914
- }
915
- // Determine part/staff assignment
916
- let partIndex = 0;
917
- let staffInPart = 1;
918
- if (scoreLayout) {
919
- const assignment = scoreLayout.get(voiceNum);
920
- if (assignment) {
921
- partIndex = assignment.partIndex;
922
- staffInPart = assignment.staffInPart;
923
- }
924
- }
925
- if (!partVoicesMap.has(partIndex)) {
926
- partVoicesMap.set(partIndex, new Map());
927
- }
928
- const voicesInPart = partVoicesMap.get(partIndex);
929
- // If there are multiple voices on same staff in same part, create separate Voice entries
930
- // Use a key combining staff + voice to avoid collisions
931
- const voiceKey = staffInPart * 1000 + voiceNum;
932
- const voice = {
933
- staff: staffInPart,
934
- events: allEvents,
935
- };
936
- voicesInPart.set(voiceKey, voice);
937
- }
938
- // Build parts from the voice map
939
- const parts = [];
940
- const sortedPartIndices = Array.from(partVoicesMap.keys()).sort((a, b) => a - b);
941
- for (const pi of sortedPartIndices) {
942
- const voicesMap = partVoicesMap.get(pi);
943
- const voices = [];
944
- const sortedKeys = Array.from(voicesMap.keys()).sort((a, b) => a - b);
945
- for (const key of sortedKeys) {
946
- voices.push(voicesMap.get(key));
947
- }
948
- const part = { voices };
949
- // Add clef context to first voice of each staff on first measure
950
- if (mi === 0) {
951
- for (const voice of voices) {
952
- // Find voices for this part's staff and add initial clef
953
- const voiceNums = Array.from(voicePatches.keys());
954
- for (const vn of voiceNums) {
955
- if (scoreLayout) {
956
- const assign = scoreLayout.get(vn);
957
- if (assign && assign.partIndex === pi && assign.staffInPart === voice.staff) {
958
- const clef = voiceClefs.get(vn);
959
- if (clef) {
960
- voice.events.unshift({ type: "context", clef });
961
- break;
962
- }
963
- }
964
- }
965
- }
966
- }
967
- }
968
- parts.push(part);
969
- }
970
- // If no parts, create a default
971
- if (parts.length === 0) {
972
- parts.push({ voices: [{ staff: 1, events: [] }] });
973
- }
974
- const measure = { parts };
975
- if (mi === 0) {
976
- if (keySig)
977
- measure.key = keySig;
978
- if (timeSig)
979
- measure.timeSig = timeSig;
980
- }
981
- lilyletMeasures.push(measure);
982
- }
983
- // Add tempo to first measure's first voice if present
984
- if (tempo && lilyletMeasures.length > 0) {
985
- const firstPart = lilyletMeasures[0].parts[0];
986
- if (firstPart && firstPart.voices.length > 0) {
987
- firstPart.voices[0].events.unshift({
988
- type: "context",
989
- tempo,
990
- });
991
- }
992
- }
993
- const doc = {
994
- measures: lilyletMeasures,
995
- };
996
- if (Object.keys(metadata).length > 0) {
997
- doc.metadata = metadata;
998
- }
999
- return doc;
1000
- };
1001
- // ============ Public API ============
1002
- /**
1003
- * Decode ABC notation string to LilyletDoc.
1004
- * If the ABC contains multiple tunes, only the first is decoded.
1005
- */
1006
- export const decode = (abcString) => {
1007
- const tunes = parse(abcString);
1008
- if (!tunes || tunes.length === 0) {
1009
- throw new Error("No tunes found in ABC notation");
1010
- }
1011
- return decodeTune(tunes[0]);
1012
- };
1013
- /**
1014
- * Decode ABC notation string to multiple LilyletDocs (one per tune).
1015
- */
1016
- export const decodeAll = (abcString) => {
1017
- const tunes = parse(abcString);
1018
- if (!tunes || tunes.length === 0) {
1019
- throw new Error("No tunes found in ABC notation");
1020
- }
1021
- return tunes.map(decodeTune);
1022
- };
1023
- /**
1024
- * Decode an ABC file to LilyletDoc
1025
- */
1026
- export const decodeFile = async (filePath) => {
1027
- const fs = await import("fs/promises");
1028
- const content = await fs.readFile(filePath, "utf-8");
1029
- return decode(content);
1030
- };
1031
- export default {
1032
- decode,
1033
- decodeAll,
1034
- decodeFile,
1035
- };