@k-l-lambda/lilylet 0.1.49 → 0.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/abc/abc.d.ts +102 -0
- package/lib/abc/abc.js +25 -0
- package/lib/abc/grammar.jison.js +1203 -0
- package/lib/abc/parser.d.ts +3 -0
- package/lib/abc/parser.js +6 -0
- package/lib/abcDecoder.d.ts +1 -0
- package/lib/abcDecoder.js +1 -0
- package/lib/grammar.jison.js +1 -1303
- package/lib/index.d.ts +1 -8
- package/lib/index.js +1 -10
- package/lib/lilylet/abcDecoder.d.ts +25 -0
- package/lib/lilylet/abcDecoder.js +1007 -0
- package/lib/lilylet/grammar.jison.js +1308 -0
- package/lib/lilylet/index.d.ts +10 -0
- package/lib/lilylet/index.js +10 -0
- package/lib/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/lilylet/lilypondDecoder.js +1053 -0
- package/lib/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/lilylet/lilypondEncoder.js +759 -0
- package/lib/lilylet/meiEncoder.d.ts +8 -0
- package/lib/lilylet/meiEncoder.js +1808 -0
- package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/lilylet/musicXmlEncoder.js +701 -0
- package/lib/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/lilylet/musicXmlTypes.js +7 -0
- package/lib/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/lilylet/musicXmlUtils.js +469 -0
- package/lib/lilylet/parser.d.ts +3 -0
- package/lib/lilylet/parser.js +151 -0
- package/lib/lilylet/serializer.d.ts +11 -0
- package/lib/lilylet/serializer.js +653 -0
- package/lib/lilylet/types.d.ts +245 -0
- package/lib/lilylet/types.js +99 -0
- package/lib/lilypondDecoder.d.ts +1 -29
- package/lib/lilypondDecoder.js +1 -1006
- package/lib/lilypondEncoder.d.ts +1 -34
- package/lib/lilypondEncoder.js +1 -759
- package/lib/meiEncoder.d.ts +1 -8
- package/lib/meiEncoder.js +1 -1545
- package/lib/musicXmlDecoder.d.ts +1 -20
- package/lib/musicXmlDecoder.js +1 -1151
- package/lib/musicXmlEncoder.d.ts +1 -15
- package/lib/musicXmlEncoder.js +1 -666
- package/lib/musicXmlTypes.d.ts +1 -199
- package/lib/musicXmlTypes.js +1 -7
- package/lib/musicXmlUtils.d.ts +1 -81
- package/lib/musicXmlUtils.js +1 -435
- package/lib/parser.d.ts +1 -3
- package/lib/parser.js +1 -151
- package/lib/serializer.d.ts +1 -11
- package/lib/serializer.js +1 -650
- package/lib/types.d.ts +1 -244
- package/lib/types.js +1 -99
- package/package.json +2 -1
- package/source/abc/abc.jison +692 -0
- package/source/abc/abc.ts +176 -0
- package/source/abc/grammar.jison.js +1203 -0
- package/source/abc/parser.ts +12 -0
- package/source/lilylet/abcDecoder.ts +1121 -0
- package/source/lilylet/grammar.jison.js +170 -165
- package/source/lilylet/index.ts +4 -3
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/lilypondDecoder.ts +91 -41
- package/source/lilylet/meiEncoder.ts +280 -0
- package/source/lilylet/musicXmlDecoder.ts +74 -27
- package/source/lilylet/musicXmlEncoder.ts +201 -146
- package/source/lilylet/musicXmlUtils.ts +46 -4
- package/source/lilylet/serializer.ts +3 -0
- package/source/lilylet/types.ts +1 -0
|
@@ -0,0 +1,1007 @@
|
|
|
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") {
|
|
699
|
+
const duration = convertDuration(eventData.duration, unitLength);
|
|
700
|
+
const rest = {
|
|
701
|
+
type: "rest",
|
|
702
|
+
duration,
|
|
703
|
+
};
|
|
704
|
+
if (firstPitch.phonet === "x") {
|
|
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").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
|
+
for (const h of headers) {
|
|
785
|
+
if (h.comment)
|
|
786
|
+
continue;
|
|
787
|
+
if (h.staffLayout)
|
|
788
|
+
continue;
|
|
789
|
+
const header = h;
|
|
790
|
+
switch (header.name) {
|
|
791
|
+
case "T":
|
|
792
|
+
if (!metadata.title)
|
|
793
|
+
metadata.title = header.value;
|
|
794
|
+
break;
|
|
795
|
+
case "C":
|
|
796
|
+
metadata.composer = header.value;
|
|
797
|
+
break;
|
|
798
|
+
case "L":
|
|
799
|
+
if (header.value?.numerator && header.value?.denominator) {
|
|
800
|
+
unitLength = header.value;
|
|
801
|
+
}
|
|
802
|
+
break;
|
|
803
|
+
case "M":
|
|
804
|
+
if (header.value?.numerator && header.value?.denominator) {
|
|
805
|
+
timeSig = {
|
|
806
|
+
numerator: header.value.numerator,
|
|
807
|
+
denominator: header.value.denominator,
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
break;
|
|
811
|
+
case "K":
|
|
812
|
+
if (header.value?.root) {
|
|
813
|
+
keySig = convertKeySignature(header.value);
|
|
814
|
+
}
|
|
815
|
+
else if (header.value?.clef) {
|
|
816
|
+
// Key header with clef only
|
|
817
|
+
}
|
|
818
|
+
break;
|
|
819
|
+
case "Q":
|
|
820
|
+
if (header.value?.note && header.value?.bpm) {
|
|
821
|
+
const beatDuration = convertDuration(header.value.note, { numerator: 1, denominator: 1 });
|
|
822
|
+
tempo = { beat: beatDuration, bpm: header.value.bpm };
|
|
823
|
+
}
|
|
824
|
+
else if (typeof header.value === "number") {
|
|
825
|
+
tempo = { bpm: header.value };
|
|
826
|
+
}
|
|
827
|
+
break;
|
|
828
|
+
case "V": {
|
|
829
|
+
const voiceValue = header.value;
|
|
830
|
+
if (voiceValue) {
|
|
831
|
+
const voiceNum = typeof voiceValue === "number" ? voiceValue :
|
|
832
|
+
(voiceValue.name || 1);
|
|
833
|
+
const clefStr = typeof voiceValue === "string" ? voiceValue :
|
|
834
|
+
(voiceValue.clef || undefined);
|
|
835
|
+
voiceConfigs.set(voiceNum, {
|
|
836
|
+
name: voiceNum,
|
|
837
|
+
clef: clefStr,
|
|
838
|
+
properties: voiceValue.properties,
|
|
839
|
+
});
|
|
840
|
+
if (clefStr) {
|
|
841
|
+
const clef = convertClef(clefStr);
|
|
842
|
+
if (clef)
|
|
843
|
+
voiceClefs.set(voiceNum, clef);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// Parse score layout
|
|
851
|
+
const scoreLayout = parseScoreLayout(headers);
|
|
852
|
+
// Group measures by voice
|
|
853
|
+
// ABC measures contain BarPatches, each with a voice control V:n
|
|
854
|
+
const measures = body.measures;
|
|
855
|
+
const voiceSlurDepths = new Map();
|
|
856
|
+
// Process each ABC measure into Lilylet Measure
|
|
857
|
+
const lilyletMeasures = [];
|
|
858
|
+
for (let mi = 0; mi < measures.length; mi++) {
|
|
859
|
+
const abcMeasure = measures[mi];
|
|
860
|
+
// Group patches by voice number
|
|
861
|
+
const voicePatches = new Map();
|
|
862
|
+
for (const patch of abcMeasure.voices) {
|
|
863
|
+
const voiceNum = patch.control?.V || 1;
|
|
864
|
+
if (!voicePatches.has(voiceNum)) {
|
|
865
|
+
voicePatches.set(voiceNum, []);
|
|
866
|
+
}
|
|
867
|
+
voicePatches.get(voiceNum).push(patch);
|
|
868
|
+
}
|
|
869
|
+
// Process each voice
|
|
870
|
+
const partVoicesMap = new Map(); // partIndex → (staffNum → voices)
|
|
871
|
+
for (const [voiceNum, patches] of voicePatches) {
|
|
872
|
+
const slurDepth = voiceSlurDepths.get(voiceNum) || { count: 0 };
|
|
873
|
+
voiceSlurDepths.set(voiceNum, slurDepth);
|
|
874
|
+
// Merge all patches for this voice in this measure
|
|
875
|
+
const allEvents = [];
|
|
876
|
+
let barline;
|
|
877
|
+
for (const patch of patches) {
|
|
878
|
+
const result = processBarPatch(patch, unitLength, slurDepth);
|
|
879
|
+
allEvents.push(...result.events);
|
|
880
|
+
if (result.barline && result.barline !== "|") {
|
|
881
|
+
barline = result.barline;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
if (barline) {
|
|
885
|
+
allEvents.push({ type: "barline", style: barline });
|
|
886
|
+
}
|
|
887
|
+
// Determine part/staff assignment
|
|
888
|
+
let partIndex = 0;
|
|
889
|
+
let staffInPart = 1;
|
|
890
|
+
if (scoreLayout) {
|
|
891
|
+
const assignment = scoreLayout.get(voiceNum);
|
|
892
|
+
if (assignment) {
|
|
893
|
+
partIndex = assignment.partIndex;
|
|
894
|
+
staffInPart = assignment.staffInPart;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (!partVoicesMap.has(partIndex)) {
|
|
898
|
+
partVoicesMap.set(partIndex, new Map());
|
|
899
|
+
}
|
|
900
|
+
const voicesInPart = partVoicesMap.get(partIndex);
|
|
901
|
+
// If there are multiple voices on same staff in same part, create separate Voice entries
|
|
902
|
+
// Use a key combining staff + voice to avoid collisions
|
|
903
|
+
const voiceKey = staffInPart * 1000 + voiceNum;
|
|
904
|
+
const voice = {
|
|
905
|
+
staff: staffInPart,
|
|
906
|
+
events: allEvents,
|
|
907
|
+
};
|
|
908
|
+
voicesInPart.set(voiceKey, voice);
|
|
909
|
+
}
|
|
910
|
+
// Build parts from the voice map
|
|
911
|
+
const parts = [];
|
|
912
|
+
const sortedPartIndices = Array.from(partVoicesMap.keys()).sort((a, b) => a - b);
|
|
913
|
+
for (const pi of sortedPartIndices) {
|
|
914
|
+
const voicesMap = partVoicesMap.get(pi);
|
|
915
|
+
const voices = [];
|
|
916
|
+
const sortedKeys = Array.from(voicesMap.keys()).sort((a, b) => a - b);
|
|
917
|
+
for (const key of sortedKeys) {
|
|
918
|
+
voices.push(voicesMap.get(key));
|
|
919
|
+
}
|
|
920
|
+
const part = { voices };
|
|
921
|
+
// Add clef context to first voice of each staff on first measure
|
|
922
|
+
if (mi === 0) {
|
|
923
|
+
for (const voice of voices) {
|
|
924
|
+
// Find voices for this part's staff and add initial clef
|
|
925
|
+
const voiceNums = Array.from(voicePatches.keys());
|
|
926
|
+
for (const vn of voiceNums) {
|
|
927
|
+
if (scoreLayout) {
|
|
928
|
+
const assign = scoreLayout.get(vn);
|
|
929
|
+
if (assign && assign.partIndex === pi && assign.staffInPart === voice.staff) {
|
|
930
|
+
const clef = voiceClefs.get(vn);
|
|
931
|
+
if (clef) {
|
|
932
|
+
voice.events.unshift({ type: "context", clef });
|
|
933
|
+
break;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
parts.push(part);
|
|
941
|
+
}
|
|
942
|
+
// If no parts, create a default
|
|
943
|
+
if (parts.length === 0) {
|
|
944
|
+
parts.push({ voices: [{ staff: 1, events: [] }] });
|
|
945
|
+
}
|
|
946
|
+
const measure = { parts };
|
|
947
|
+
if (mi === 0) {
|
|
948
|
+
if (keySig)
|
|
949
|
+
measure.key = keySig;
|
|
950
|
+
if (timeSig)
|
|
951
|
+
measure.timeSig = timeSig;
|
|
952
|
+
}
|
|
953
|
+
lilyletMeasures.push(measure);
|
|
954
|
+
}
|
|
955
|
+
// Add tempo to first measure's first voice if present
|
|
956
|
+
if (tempo && lilyletMeasures.length > 0) {
|
|
957
|
+
const firstPart = lilyletMeasures[0].parts[0];
|
|
958
|
+
if (firstPart && firstPart.voices.length > 0) {
|
|
959
|
+
firstPart.voices[0].events.unshift({
|
|
960
|
+
type: "context",
|
|
961
|
+
tempo,
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const doc = {
|
|
966
|
+
measures: lilyletMeasures,
|
|
967
|
+
};
|
|
968
|
+
if (Object.keys(metadata).length > 0) {
|
|
969
|
+
doc.metadata = metadata;
|
|
970
|
+
}
|
|
971
|
+
return doc;
|
|
972
|
+
};
|
|
973
|
+
// ============ Public API ============
|
|
974
|
+
/**
|
|
975
|
+
* Decode ABC notation string to LilyletDoc.
|
|
976
|
+
* If the ABC contains multiple tunes, only the first is decoded.
|
|
977
|
+
*/
|
|
978
|
+
export const decode = (abcString) => {
|
|
979
|
+
const tunes = parse(abcString);
|
|
980
|
+
if (!tunes || tunes.length === 0) {
|
|
981
|
+
throw new Error("No tunes found in ABC notation");
|
|
982
|
+
}
|
|
983
|
+
return decodeTune(tunes[0]);
|
|
984
|
+
};
|
|
985
|
+
/**
|
|
986
|
+
* Decode ABC notation string to multiple LilyletDocs (one per tune).
|
|
987
|
+
*/
|
|
988
|
+
export const decodeAll = (abcString) => {
|
|
989
|
+
const tunes = parse(abcString);
|
|
990
|
+
if (!tunes || tunes.length === 0) {
|
|
991
|
+
throw new Error("No tunes found in ABC notation");
|
|
992
|
+
}
|
|
993
|
+
return tunes.map(decodeTune);
|
|
994
|
+
};
|
|
995
|
+
/**
|
|
996
|
+
* Decode an ABC file to LilyletDoc
|
|
997
|
+
*/
|
|
998
|
+
export const decodeFile = async (filePath) => {
|
|
999
|
+
const fs = await import("fs/promises");
|
|
1000
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
1001
|
+
return decode(content);
|
|
1002
|
+
};
|
|
1003
|
+
export default {
|
|
1004
|
+
decode,
|
|
1005
|
+
decodeAll,
|
|
1006
|
+
decodeFile,
|
|
1007
|
+
};
|