@k-l-lambda/lilylet 0.1.48 → 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 +92 -42
- 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,1808 @@
|
|
|
1
|
+
import { Clef, Accidental, OrnamentType, StemDirection, HairpinType, PedalType, } from "./types.js";
|
|
2
|
+
// MEI key signatures: positive = sharps, negative = flats
|
|
3
|
+
const KEY_SIGS = {
|
|
4
|
+
0: "0",
|
|
5
|
+
1: "1s",
|
|
6
|
+
2: "2s",
|
|
7
|
+
3: "3s",
|
|
8
|
+
4: "4s",
|
|
9
|
+
5: "5s",
|
|
10
|
+
6: "6s",
|
|
11
|
+
7: "7s",
|
|
12
|
+
[-1]: "1f",
|
|
13
|
+
[-2]: "2f",
|
|
14
|
+
[-3]: "3f",
|
|
15
|
+
[-4]: "4f",
|
|
16
|
+
[-5]: "5f",
|
|
17
|
+
[-6]: "6f",
|
|
18
|
+
[-7]: "7f",
|
|
19
|
+
};
|
|
20
|
+
// Key signature to fifths number
|
|
21
|
+
const keyToFifths = (key) => {
|
|
22
|
+
if (!key)
|
|
23
|
+
return 0;
|
|
24
|
+
// Major keys
|
|
25
|
+
const majorKeys = {
|
|
26
|
+
'c': 0, 'd': 2, 'e': 4, 'f': -1, 'g': 1, 'a': 3, 'b': 5,
|
|
27
|
+
};
|
|
28
|
+
let fifths = majorKeys[key.pitch] || 0;
|
|
29
|
+
if (key.accidental === Accidental.sharp)
|
|
30
|
+
fifths += 7;
|
|
31
|
+
else if (key.accidental === Accidental.flat)
|
|
32
|
+
fifths -= 7;
|
|
33
|
+
if (key.mode === 'minor')
|
|
34
|
+
fifths -= 3;
|
|
35
|
+
// Clamp to valid range [-7, 7] since standard notation doesn't support more than 7 sharps/flats
|
|
36
|
+
return Math.max(-7, Math.min(7, fifths));
|
|
37
|
+
};
|
|
38
|
+
const CLEF_SHAPES = {
|
|
39
|
+
treble: { shape: "G", line: 2 },
|
|
40
|
+
bass: { shape: "F", line: 4 },
|
|
41
|
+
alto: { shape: "C", line: 3 },
|
|
42
|
+
// Also support uppercase letter clef names
|
|
43
|
+
G: { shape: "G", line: 2 },
|
|
44
|
+
F: { shape: "F", line: 4 },
|
|
45
|
+
C: { shape: "C", line: 3 },
|
|
46
|
+
};
|
|
47
|
+
// Lilylet duration division to MEI dur
|
|
48
|
+
// division: 1=whole, 2=half, 4=quarter, 8=eighth, etc.
|
|
49
|
+
const DURATIONS = {
|
|
50
|
+
1: "1", // whole
|
|
51
|
+
2: "2", // half
|
|
52
|
+
4: "4", // quarter
|
|
53
|
+
8: "8", // eighth
|
|
54
|
+
16: "16",
|
|
55
|
+
32: "32",
|
|
56
|
+
64: "64",
|
|
57
|
+
128: "128",
|
|
58
|
+
};
|
|
59
|
+
// Accidental mapping
|
|
60
|
+
const ACCIDENTALS = {
|
|
61
|
+
natural: "n",
|
|
62
|
+
sharp: "s",
|
|
63
|
+
flat: "f",
|
|
64
|
+
doubleSharp: "x",
|
|
65
|
+
doubleFlat: "ff",
|
|
66
|
+
};
|
|
67
|
+
// Articulation to MEI artic
|
|
68
|
+
const ARTIC_MAP = {
|
|
69
|
+
staccato: "stacc",
|
|
70
|
+
staccatissimo: "stacciss",
|
|
71
|
+
tenuto: "ten",
|
|
72
|
+
marcato: "marc",
|
|
73
|
+
accent: "acc",
|
|
74
|
+
portato: "stacc ten", // Both staccato and tenuto (portato)
|
|
75
|
+
};
|
|
76
|
+
// Dynamic to MEI
|
|
77
|
+
const DYNAMIC_MAP = {
|
|
78
|
+
ppp: "ppp",
|
|
79
|
+
pp: "pp",
|
|
80
|
+
p: "p",
|
|
81
|
+
mp: "mp",
|
|
82
|
+
mf: "mf",
|
|
83
|
+
f: "f",
|
|
84
|
+
ff: "ff",
|
|
85
|
+
fff: "fff",
|
|
86
|
+
sfz: "sfz",
|
|
87
|
+
rfz: "rfz",
|
|
88
|
+
};
|
|
89
|
+
// ID generation state - uses session prefix to prevent collisions in concurrent encoding
|
|
90
|
+
let idCounter = 0;
|
|
91
|
+
let sessionPrefix = '';
|
|
92
|
+
const generateId = (prefix) => {
|
|
93
|
+
return `${prefix}-${sessionPrefix}${String(++idCounter).padStart(10, "0")}`;
|
|
94
|
+
};
|
|
95
|
+
const resetIdCounter = () => {
|
|
96
|
+
idCounter = 0;
|
|
97
|
+
// Generate a unique 4-char hex session prefix for this encode call
|
|
98
|
+
sessionPrefix = Math.random().toString(16).substring(2, 6);
|
|
99
|
+
};
|
|
100
|
+
// Sharp and flat order for key signatures (circle of fifths)
|
|
101
|
+
const SHARP_ORDER = ['f', 'c', 'g', 'd', 'a', 'e', 'b'];
|
|
102
|
+
const FLAT_ORDER = ['b', 'e', 'a', 'd', 'g', 'c', 'f'];
|
|
103
|
+
// Get the accidentals implied by a key signature
|
|
104
|
+
// fifths > 0 = sharps, fifths < 0 = flats
|
|
105
|
+
const getKeyAccidentals = (fifths) => {
|
|
106
|
+
const result = {};
|
|
107
|
+
if (fifths > 0) {
|
|
108
|
+
// Sharps
|
|
109
|
+
for (let i = 0; i < Math.min(fifths, 7); i++) {
|
|
110
|
+
result[SHARP_ORDER[i]] = 's'; // sharp
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
else if (fifths < 0) {
|
|
114
|
+
// Flats
|
|
115
|
+
for (let i = 0; i < Math.min(-fifths, 7); i++) {
|
|
116
|
+
result[FLAT_ORDER[i]] = 'f'; // flat
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
};
|
|
121
|
+
// Convert Pitch to MEI attributes, checking against key signature
|
|
122
|
+
// ottavaShift: current ottava level (1 = 8va up, -1 = 8vb down, 2 = 15ma up, etc.)
|
|
123
|
+
// The written pitch should be adjusted by subtracting the ottava shift
|
|
124
|
+
const encodePitch = (pitch, keyFifths = 0, ottavaShift = 0) => {
|
|
125
|
+
// Lilylet octave: 0 = middle C octave (C4), positive = higher, negative = lower
|
|
126
|
+
// When ottava is active, the source pitch is the sounding pitch, but we need to output the written pitch
|
|
127
|
+
// For 8va up (ottavaShift=1), written pitch is one octave lower than sounding
|
|
128
|
+
const oct = 4 + pitch.octave - ottavaShift;
|
|
129
|
+
// Get the accidental implied by the key signature for this note
|
|
130
|
+
const keyAccidentals = getKeyAccidentals(keyFifths);
|
|
131
|
+
const keyAccid = keyAccidentals[pitch.phonet];
|
|
132
|
+
// Determine accid (written/displayed) and accid.ges (gestural/sounding)
|
|
133
|
+
let accid;
|
|
134
|
+
let accidGes;
|
|
135
|
+
if (pitch.accidental) {
|
|
136
|
+
const noteAccid = ACCIDENTALS[pitch.accidental];
|
|
137
|
+
if (noteAccid !== keyAccid) {
|
|
138
|
+
// Accidental differs from key signature - display it
|
|
139
|
+
accid = noteAccid;
|
|
140
|
+
}
|
|
141
|
+
// Always set gestural accidental for MIDI generation
|
|
142
|
+
accidGes = noteAccid;
|
|
143
|
+
}
|
|
144
|
+
else if (keyAccid) {
|
|
145
|
+
// Note has no accidental but key implies one - output natural
|
|
146
|
+
accid = 'n';
|
|
147
|
+
accidGes = 'n';
|
|
148
|
+
}
|
|
149
|
+
return { pname: pitch.phonet, oct, accid, accidGes };
|
|
150
|
+
};
|
|
151
|
+
// Convert tremolo division to stem.mod value
|
|
152
|
+
const tremoloToStemMod = (division) => {
|
|
153
|
+
// 8 = 1slash (eighth note strokes), 16 = 2slash, 32 = 3slash, etc.
|
|
154
|
+
const slashes = Math.log2(division) - 2; // 8->1, 16->2, 32->3
|
|
155
|
+
if (slashes >= 1 && slashes <= 6) {
|
|
156
|
+
return `${slashes}slash`;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
};
|
|
160
|
+
// Build note element
|
|
161
|
+
const buildNoteElement = (pitch, dur, dots, indent, inChord, options = {}, noteId) => {
|
|
162
|
+
const id = noteId || generateId('note');
|
|
163
|
+
let attrs = `xml:id="${id}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
164
|
+
if (!inChord) {
|
|
165
|
+
attrs += ` dur="${dur}"`;
|
|
166
|
+
}
|
|
167
|
+
if (pitch.accid)
|
|
168
|
+
attrs += ` accid="${pitch.accid}"`;
|
|
169
|
+
if (pitch.accidGes)
|
|
170
|
+
attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
171
|
+
if (!inChord && dots > 0)
|
|
172
|
+
attrs += ` dots="${dots}"`;
|
|
173
|
+
if (!inChord && options.grace)
|
|
174
|
+
attrs += ` grace="unacc"`;
|
|
175
|
+
if (!inChord && options.tie)
|
|
176
|
+
attrs += ` tie="${options.tie}"`;
|
|
177
|
+
if (!inChord && options.stemDir)
|
|
178
|
+
attrs += ` stem.dir="${options.stemDir}"`;
|
|
179
|
+
if (!inChord && options.layerStaff && options.staff && options.staff !== options.layerStaff) {
|
|
180
|
+
attrs += ` staff="${options.staff}"`;
|
|
181
|
+
}
|
|
182
|
+
if (!inChord && options.tremolo) {
|
|
183
|
+
const stemMod = tremoloToStemMod(options.tremolo);
|
|
184
|
+
if (stemMod)
|
|
185
|
+
attrs += ` stem.mod="${stemMod}"`;
|
|
186
|
+
}
|
|
187
|
+
// Only artics remain as child elements; ornaments are control events
|
|
188
|
+
const hasChildren = !inChord && options.artics && options.artics.length > 0;
|
|
189
|
+
if (!hasChildren) {
|
|
190
|
+
return `${indent}<note ${attrs} />\n`;
|
|
191
|
+
}
|
|
192
|
+
let result = `${indent}<note ${attrs}>\n`;
|
|
193
|
+
if (options.artics && options.artics.length > 0) {
|
|
194
|
+
// Group artics by placement
|
|
195
|
+
const aboveArtics = options.artics.filter(a => a.placement === 'above').map(a => a.type);
|
|
196
|
+
const belowArtics = options.artics.filter(a => a.placement === 'below').map(a => a.type);
|
|
197
|
+
const defaultArtics = options.artics.filter(a => !a.placement).map(a => a.type);
|
|
198
|
+
if (aboveArtics.length > 0) {
|
|
199
|
+
result += `${indent} <artic artic="${aboveArtics.join(' ')}" place="above" />\n`;
|
|
200
|
+
}
|
|
201
|
+
if (belowArtics.length > 0) {
|
|
202
|
+
result += `${indent} <artic artic="${belowArtics.join(' ')}" place="below" />\n`;
|
|
203
|
+
}
|
|
204
|
+
if (defaultArtics.length > 0) {
|
|
205
|
+
result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
result += `${indent}</note>\n`;
|
|
209
|
+
return result;
|
|
210
|
+
};
|
|
211
|
+
// Extract mark properties from note event
|
|
212
|
+
const extractMarkOptions = (marks) => {
|
|
213
|
+
const result = {
|
|
214
|
+
artics: [],
|
|
215
|
+
fermata: false,
|
|
216
|
+
trill: false,
|
|
217
|
+
arpeggio: false,
|
|
218
|
+
turn: false,
|
|
219
|
+
mordent: false, // lower = mordent, upper = prall
|
|
220
|
+
slurStart: false,
|
|
221
|
+
slurEnd: false,
|
|
222
|
+
tieStart: false,
|
|
223
|
+
beamStart: false,
|
|
224
|
+
beamEnd: false,
|
|
225
|
+
dynamic: undefined,
|
|
226
|
+
hairpin: undefined,
|
|
227
|
+
pedal: undefined,
|
|
228
|
+
tremolo: undefined,
|
|
229
|
+
fingerings: [],
|
|
230
|
+
navigation: undefined,
|
|
231
|
+
markups: [],
|
|
232
|
+
};
|
|
233
|
+
if (!marks)
|
|
234
|
+
return result;
|
|
235
|
+
for (const mark of marks) {
|
|
236
|
+
switch (mark.markType) {
|
|
237
|
+
case 'articulation': {
|
|
238
|
+
const articType = ARTIC_MAP[mark.type];
|
|
239
|
+
if (articType) {
|
|
240
|
+
result.artics.push({
|
|
241
|
+
type: articType,
|
|
242
|
+
placement: mark.placement,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case 'ornament':
|
|
248
|
+
if (mark.type === OrnamentType.fermata) {
|
|
249
|
+
result.fermata = 'normal';
|
|
250
|
+
}
|
|
251
|
+
else if (mark.type === OrnamentType.shortFermata) {
|
|
252
|
+
result.fermata = 'short';
|
|
253
|
+
}
|
|
254
|
+
else if (mark.type === OrnamentType.trill) {
|
|
255
|
+
result.trill = true;
|
|
256
|
+
}
|
|
257
|
+
else if (mark.type === OrnamentType.arpeggio) {
|
|
258
|
+
result.arpeggio = true;
|
|
259
|
+
}
|
|
260
|
+
else if (mark.type === OrnamentType.turn) {
|
|
261
|
+
result.turn = true;
|
|
262
|
+
}
|
|
263
|
+
else if (mark.type === OrnamentType.mordent) {
|
|
264
|
+
result.mordent = 'lower';
|
|
265
|
+
}
|
|
266
|
+
else if (mark.type === OrnamentType.prall) {
|
|
267
|
+
result.mordent = 'upper';
|
|
268
|
+
}
|
|
269
|
+
break;
|
|
270
|
+
case 'dynamic': {
|
|
271
|
+
const dynStr = DYNAMIC_MAP[mark.type];
|
|
272
|
+
if (dynStr) {
|
|
273
|
+
result.dynamic = dynStr;
|
|
274
|
+
}
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case 'hairpin':
|
|
278
|
+
if (mark.type === HairpinType.crescendoStart) {
|
|
279
|
+
result.hairpin = 'crescStart';
|
|
280
|
+
}
|
|
281
|
+
else if (mark.type === HairpinType.diminuendoStart) {
|
|
282
|
+
result.hairpin = 'dimStart';
|
|
283
|
+
}
|
|
284
|
+
else if (mark.type === HairpinType.crescendoEnd || mark.type === HairpinType.diminuendoEnd) {
|
|
285
|
+
result.hairpin = 'end';
|
|
286
|
+
}
|
|
287
|
+
break;
|
|
288
|
+
case 'pedal':
|
|
289
|
+
if (mark.type === PedalType.sustainOn) {
|
|
290
|
+
result.pedal = 'down';
|
|
291
|
+
}
|
|
292
|
+
else if (mark.type === PedalType.sustainOff) {
|
|
293
|
+
result.pedal = 'up';
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
case 'tie':
|
|
297
|
+
if (mark.start) {
|
|
298
|
+
result.tieStart = true;
|
|
299
|
+
}
|
|
300
|
+
break;
|
|
301
|
+
case 'slur':
|
|
302
|
+
if (mark.start) {
|
|
303
|
+
result.slurStart = true;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
result.slurEnd = true;
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
case 'beam':
|
|
310
|
+
if (mark.start) {
|
|
311
|
+
result.beamStart = true;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
result.beamEnd = true;
|
|
315
|
+
}
|
|
316
|
+
break;
|
|
317
|
+
case 'fingering':
|
|
318
|
+
result.fingerings.push({
|
|
319
|
+
finger: mark.finger,
|
|
320
|
+
placement: mark.placement,
|
|
321
|
+
});
|
|
322
|
+
break;
|
|
323
|
+
case 'navigation':
|
|
324
|
+
result.navigation = mark.type;
|
|
325
|
+
break;
|
|
326
|
+
case 'markup':
|
|
327
|
+
result.markups.push({
|
|
328
|
+
content: mark.content,
|
|
329
|
+
placement: mark.placement,
|
|
330
|
+
});
|
|
331
|
+
break;
|
|
332
|
+
}
|
|
333
|
+
// Tremolo (special case - from parser internal mark)
|
|
334
|
+
if ('tremolo' in mark && typeof mark.tremolo === 'number') {
|
|
335
|
+
result.tremolo = mark.tremolo;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return result;
|
|
339
|
+
};
|
|
340
|
+
// Convert NoteEvent to MEI
|
|
341
|
+
const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFifths = 0, ottavaShift = 0) => {
|
|
342
|
+
const dur = DURATIONS[event.duration.division] || "4";
|
|
343
|
+
const dots = event.duration.dots || 0;
|
|
344
|
+
const markOptions = extractMarkOptions(event.marks);
|
|
345
|
+
// Stem direction - use event's own or context's
|
|
346
|
+
const effectiveStemDir = event.stemDirection ?? contextStemDir;
|
|
347
|
+
let stemDir;
|
|
348
|
+
if (effectiveStemDir === StemDirection.up)
|
|
349
|
+
stemDir = 'up';
|
|
350
|
+
else if (effectiveStemDir === StemDirection.down)
|
|
351
|
+
stemDir = 'down';
|
|
352
|
+
// Determine tie attribute: 'i' = initial, 'm' = medial, 't' = terminal
|
|
353
|
+
let tie;
|
|
354
|
+
if (markOptions.tieStart && tieEnd) {
|
|
355
|
+
tie = 'm'; // Both start and end = medial
|
|
356
|
+
}
|
|
357
|
+
else if (markOptions.tieStart) {
|
|
358
|
+
tie = 'i'; // Start only = initial
|
|
359
|
+
}
|
|
360
|
+
else if (tieEnd) {
|
|
361
|
+
tie = 't'; // End only = terminal
|
|
362
|
+
}
|
|
363
|
+
// Note options - ornaments are now control events, not inline
|
|
364
|
+
const noteOptions = {
|
|
365
|
+
grace: event.grace,
|
|
366
|
+
tie,
|
|
367
|
+
stemDir,
|
|
368
|
+
staff: event.staff,
|
|
369
|
+
layerStaff,
|
|
370
|
+
artics: markOptions.artics,
|
|
371
|
+
tremolo: markOptions.tremolo,
|
|
372
|
+
};
|
|
373
|
+
// Single note
|
|
374
|
+
if (event.pitches.length === 1) {
|
|
375
|
+
const pitch = encodePitch(event.pitches[0], keyFifths, ottavaShift);
|
|
376
|
+
const noteId = generateId('note');
|
|
377
|
+
return {
|
|
378
|
+
xml: buildNoteElement(pitch, dur, dots, indent, false, noteOptions, noteId),
|
|
379
|
+
elementId: noteId,
|
|
380
|
+
hairpin: markOptions.hairpin,
|
|
381
|
+
pedal: markOptions.pedal,
|
|
382
|
+
hasTieStart: markOptions.tieStart,
|
|
383
|
+
pitches: event.pitches,
|
|
384
|
+
arpeggio: markOptions.arpeggio,
|
|
385
|
+
fermata: markOptions.fermata,
|
|
386
|
+
trill: markOptions.trill,
|
|
387
|
+
mordent: markOptions.mordent,
|
|
388
|
+
turn: markOptions.turn,
|
|
389
|
+
dynamic: markOptions.dynamic,
|
|
390
|
+
slurStart: markOptions.slurStart,
|
|
391
|
+
slurEnd: markOptions.slurEnd,
|
|
392
|
+
fingerings: markOptions.fingerings,
|
|
393
|
+
navigation: markOptions.navigation,
|
|
394
|
+
markups: markOptions.markups,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// Chord
|
|
398
|
+
const chordId = generateId('chord');
|
|
399
|
+
let chordAttrs = `xml:id="${chordId}" dur="${dur}"`;
|
|
400
|
+
if (dots > 0)
|
|
401
|
+
chordAttrs += ` dots="${dots}"`;
|
|
402
|
+
if (noteOptions.grace)
|
|
403
|
+
chordAttrs += ` grace="unacc"`;
|
|
404
|
+
if (noteOptions.tie)
|
|
405
|
+
chordAttrs += ` tie="${noteOptions.tie}"`;
|
|
406
|
+
if (noteOptions.stemDir)
|
|
407
|
+
chordAttrs += ` stem.dir="${noteOptions.stemDir}"`;
|
|
408
|
+
if (layerStaff && noteOptions.staff && noteOptions.staff !== layerStaff) {
|
|
409
|
+
chordAttrs += ` staff="${noteOptions.staff}"`;
|
|
410
|
+
}
|
|
411
|
+
let result = `${indent}<chord ${chordAttrs}>\n`;
|
|
412
|
+
for (const p of event.pitches) {
|
|
413
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
414
|
+
result += buildNoteElement(pitch, dur, dots, indent + ' ', true);
|
|
415
|
+
}
|
|
416
|
+
// Artics for chord - group by placement
|
|
417
|
+
if (noteOptions.artics.length > 0) {
|
|
418
|
+
const aboveArtics = noteOptions.artics.filter(a => a.placement === 'above').map(a => a.type);
|
|
419
|
+
const belowArtics = noteOptions.artics.filter(a => a.placement === 'below').map(a => a.type);
|
|
420
|
+
const defaultArtics = noteOptions.artics.filter(a => !a.placement).map(a => a.type);
|
|
421
|
+
if (aboveArtics.length > 0) {
|
|
422
|
+
result += `${indent} <artic artic="${aboveArtics.join(' ')}" place="above" />\n`;
|
|
423
|
+
}
|
|
424
|
+
if (belowArtics.length > 0) {
|
|
425
|
+
result += `${indent} <artic artic="${belowArtics.join(' ')}" place="below" />\n`;
|
|
426
|
+
}
|
|
427
|
+
if (defaultArtics.length > 0) {
|
|
428
|
+
result += `${indent} <artic artic="${defaultArtics.join(' ')}" />\n`;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
result += `${indent}</chord>\n`;
|
|
432
|
+
return {
|
|
433
|
+
xml: result,
|
|
434
|
+
elementId: chordId,
|
|
435
|
+
hairpin: markOptions.hairpin,
|
|
436
|
+
pedal: markOptions.pedal,
|
|
437
|
+
hasTieStart: markOptions.tieStart,
|
|
438
|
+
pitches: event.pitches,
|
|
439
|
+
arpeggio: markOptions.arpeggio,
|
|
440
|
+
fermata: markOptions.fermata,
|
|
441
|
+
trill: markOptions.trill,
|
|
442
|
+
mordent: markOptions.mordent,
|
|
443
|
+
turn: markOptions.turn,
|
|
444
|
+
dynamic: markOptions.dynamic,
|
|
445
|
+
slurStart: markOptions.slurStart,
|
|
446
|
+
slurEnd: markOptions.slurEnd,
|
|
447
|
+
fingerings: markOptions.fingerings,
|
|
448
|
+
navigation: markOptions.navigation,
|
|
449
|
+
markups: markOptions.markups,
|
|
450
|
+
};
|
|
451
|
+
};
|
|
452
|
+
// Convert RestEvent to MEI
|
|
453
|
+
const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
454
|
+
const dur = DURATIONS[event.duration.division] || "4";
|
|
455
|
+
let attrs = `xml:id="${generateId('rest')}" dur="${dur}"`;
|
|
456
|
+
if (event.duration.dots > 0)
|
|
457
|
+
attrs += ` dots="${event.duration.dots}"`;
|
|
458
|
+
// Pitched rest (positioned at specific pitch)
|
|
459
|
+
if (event.pitch) {
|
|
460
|
+
const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
|
|
461
|
+
attrs += ` ploc="${pitch.pname}" oloc="${pitch.oct}"`;
|
|
462
|
+
}
|
|
463
|
+
// Space rest (invisible)
|
|
464
|
+
if (event.invisible) {
|
|
465
|
+
return `${indent}<space ${attrs} />\n`;
|
|
466
|
+
}
|
|
467
|
+
// Full measure rest
|
|
468
|
+
if (event.fullMeasure) {
|
|
469
|
+
return `${indent}<mRest xml:id="${generateId('mrest')}" />\n`;
|
|
470
|
+
}
|
|
471
|
+
return `${indent}<rest ${attrs} />\n`;
|
|
472
|
+
};
|
|
473
|
+
// Convert TupletEvent to MEI
|
|
474
|
+
const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff, ottavaShift = 0, inParentBeam = false) => {
|
|
475
|
+
// LilyPond \times 2/3 means "multiply duration by 2/3"
|
|
476
|
+
// So 3 notes × 2/3 = 2 beats worth (3 in time of 2)
|
|
477
|
+
// MEI: num = number of notes written, numbase = normal equivalent
|
|
478
|
+
const num = event.ratio.denominator; // denominator = actual note count
|
|
479
|
+
const numbase = event.ratio.numerator; // numerator = time equivalent
|
|
480
|
+
let xml = `${indent}<tuplet xml:id="${generateId('tuplet')}" num="${num}" numbase="${numbase}">\n`;
|
|
481
|
+
const baseIndent = indent + ' ';
|
|
482
|
+
// Effective staff for cross-staff notation
|
|
483
|
+
const effectiveStaff = currentStaff ?? layerStaff;
|
|
484
|
+
// Collect control event info from notes inside tuplet
|
|
485
|
+
const slurStarts = [];
|
|
486
|
+
const slurEnds = [];
|
|
487
|
+
const dynamics = [];
|
|
488
|
+
const fermatas = [];
|
|
489
|
+
const trills = [];
|
|
490
|
+
const mordents = [];
|
|
491
|
+
const turns = [];
|
|
492
|
+
const arpeggios = [];
|
|
493
|
+
// If we're inside a parent beam, don't create internal beams - notes go directly into tuplet
|
|
494
|
+
// MEI allows: <beam><tuplet><note/>...</tuplet><tuplet><note/>...</tuplet></beam>
|
|
495
|
+
// Beam state is managed by encodeLayer, not here.
|
|
496
|
+
for (const e of event.events) {
|
|
497
|
+
if (e.type === 'note') {
|
|
498
|
+
// For cross-staff notation: set note's staff if different from layerStaff
|
|
499
|
+
const noteEvent = e;
|
|
500
|
+
const effectiveNoteEvent = effectiveStaff && layerStaff && effectiveStaff !== layerStaff
|
|
501
|
+
? { ...noteEvent, staff: effectiveStaff }
|
|
502
|
+
: noteEvent;
|
|
503
|
+
const result = noteEventToMEI(effectiveNoteEvent, baseIndent, layerStaff, false, undefined, keyFifths, ottavaShift);
|
|
504
|
+
xml += result.xml;
|
|
505
|
+
// Collect slur info
|
|
506
|
+
if (result.slurStart)
|
|
507
|
+
slurStarts.push(result.elementId);
|
|
508
|
+
if (result.slurEnd)
|
|
509
|
+
slurEnds.push(result.elementId);
|
|
510
|
+
// Collect other control events
|
|
511
|
+
if (result.dynamic)
|
|
512
|
+
dynamics.push({ startid: result.elementId, label: result.dynamic });
|
|
513
|
+
if (result.fermata)
|
|
514
|
+
fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
|
|
515
|
+
if (result.trill)
|
|
516
|
+
trills.push({ startid: result.elementId });
|
|
517
|
+
if (result.mordent)
|
|
518
|
+
mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
|
|
519
|
+
if (result.turn)
|
|
520
|
+
turns.push({ startid: result.elementId });
|
|
521
|
+
if (result.arpeggio)
|
|
522
|
+
arpeggios.push({ plist: result.elementId });
|
|
523
|
+
}
|
|
524
|
+
else if (e.type === 'rest') {
|
|
525
|
+
xml += restEventToMEI(e, baseIndent, keyFifths, ottavaShift);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
xml += `${indent}</tuplet>\n`;
|
|
529
|
+
return { xml, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios };
|
|
530
|
+
};
|
|
531
|
+
// Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
|
|
532
|
+
const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0) => {
|
|
533
|
+
const ftremId = generateId('fTrem');
|
|
534
|
+
// For \repeat tremolo 4 { c16 d16 }:
|
|
535
|
+
// - count = 4 (repetitions)
|
|
536
|
+
// - division = 16 (note value)
|
|
537
|
+
// - Total duration = 4 × 2 × 16th = 8 × 16th = half note
|
|
538
|
+
// - Each visible note = half of total = quarter note
|
|
539
|
+
// Calculate beams (tremolo strokes) based on division
|
|
540
|
+
// 8th = 1 beam, 16th = 2 beams, 32nd = 3 beams
|
|
541
|
+
const beams = Math.max(1, Math.log2(event.division / 8) + 1);
|
|
542
|
+
// Calculate visual duration for each note
|
|
543
|
+
// For \repeat tremolo 4 { c16 d16 }:
|
|
544
|
+
// - Total strokes = 4 × 2 = 8 sixteenth notes = 1/2 whole note
|
|
545
|
+
// - Each visible note = 1/4 whole note = quarter note (dur="4")
|
|
546
|
+
// Formula: dur = division / count (e.g., 16 / 4 = 4 for quarter note)
|
|
547
|
+
const noteDur = Math.round(event.division / event.count) || 4; // Default to quarter if calculation fails
|
|
548
|
+
let result = `${indent}<fTrem xml:id="${ftremId}" beams="${beams}">\n`;
|
|
549
|
+
// First note (or chord)
|
|
550
|
+
if (event.pitchA.length === 1) {
|
|
551
|
+
const pitch = encodePitch(event.pitchA[0], keyFifths, ottavaShift);
|
|
552
|
+
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
553
|
+
if (pitch.accid)
|
|
554
|
+
attrs += ` accid="${pitch.accid}"`;
|
|
555
|
+
if (pitch.accidGes)
|
|
556
|
+
attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
557
|
+
result += `${indent} <note ${attrs} />\n`;
|
|
558
|
+
}
|
|
559
|
+
else if (event.pitchA.length > 1) {
|
|
560
|
+
result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
|
|
561
|
+
for (const p of event.pitchA) {
|
|
562
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
563
|
+
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
564
|
+
if (pitch.accid)
|
|
565
|
+
attrs += ` accid="${pitch.accid}"`;
|
|
566
|
+
if (pitch.accidGes)
|
|
567
|
+
attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
568
|
+
result += `${indent} <note ${attrs} />\n`;
|
|
569
|
+
}
|
|
570
|
+
result += `${indent} </chord>\n`;
|
|
571
|
+
}
|
|
572
|
+
// Second note (or chord)
|
|
573
|
+
if (event.pitchB.length === 1) {
|
|
574
|
+
const pitch = encodePitch(event.pitchB[0], keyFifths, ottavaShift);
|
|
575
|
+
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}" dur="${noteDur}"`;
|
|
576
|
+
if (pitch.accid)
|
|
577
|
+
attrs += ` accid="${pitch.accid}"`;
|
|
578
|
+
if (pitch.accidGes)
|
|
579
|
+
attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
580
|
+
result += `${indent} <note ${attrs} />\n`;
|
|
581
|
+
}
|
|
582
|
+
else if (event.pitchB.length > 1) {
|
|
583
|
+
result += `${indent} <chord xml:id="${generateId('chord')}" dur="${noteDur}">\n`;
|
|
584
|
+
for (const p of event.pitchB) {
|
|
585
|
+
const pitch = encodePitch(p, keyFifths, ottavaShift);
|
|
586
|
+
let attrs = `xml:id="${generateId('note')}" pname="${pitch.pname}" oct="${pitch.oct}"`;
|
|
587
|
+
if (pitch.accid)
|
|
588
|
+
attrs += ` accid="${pitch.accid}"`;
|
|
589
|
+
if (pitch.accidGes)
|
|
590
|
+
attrs += ` accid.ges="${pitch.accidGes}"`;
|
|
591
|
+
result += `${indent} <note ${attrs} />\n`;
|
|
592
|
+
}
|
|
593
|
+
result += `${indent} </chord>\n`;
|
|
594
|
+
}
|
|
595
|
+
result += `${indent}</fTrem>\n`;
|
|
596
|
+
return result;
|
|
597
|
+
};
|
|
598
|
+
// Helper: check if an event (or any note inside a tuplet) has beam start/end
|
|
599
|
+
const getEventBeamMarks = (event) => {
|
|
600
|
+
if (event.type === 'note') {
|
|
601
|
+
const markOptions = extractMarkOptions(event.marks);
|
|
602
|
+
return { beamStart: markOptions.beamStart, beamEnd: markOptions.beamEnd };
|
|
603
|
+
}
|
|
604
|
+
if (event.type === 'tuplet') {
|
|
605
|
+
const tuplet = event;
|
|
606
|
+
let beamStart = false;
|
|
607
|
+
let beamEnd = false;
|
|
608
|
+
for (const e of tuplet.events) {
|
|
609
|
+
if (e.type === 'note') {
|
|
610
|
+
const markOptions = extractMarkOptions(e.marks);
|
|
611
|
+
if (markOptions.beamStart)
|
|
612
|
+
beamStart = true;
|
|
613
|
+
if (markOptions.beamEnd)
|
|
614
|
+
beamEnd = true;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return { beamStart, beamEnd };
|
|
618
|
+
}
|
|
619
|
+
return { beamStart: false, beamEnd: false };
|
|
620
|
+
};
|
|
621
|
+
// Encode a layer (voice)
|
|
622
|
+
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlur = null, initialHairpin = null, initialOctave = null) => {
|
|
623
|
+
const layerId = generateId("layer");
|
|
624
|
+
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
625
|
+
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
626
|
+
const baseIndent = indent + ' ';
|
|
627
|
+
// Track current clef to only emit changes
|
|
628
|
+
let currentClef = initialClef;
|
|
629
|
+
// Track hairpin spans
|
|
630
|
+
const hairpins = [];
|
|
631
|
+
let currentHairpin = initialHairpin;
|
|
632
|
+
// Track pedal marks (each is independent, not paired spans)
|
|
633
|
+
const pedals = [];
|
|
634
|
+
// Track octave spans - initialize from previous measure if continuing
|
|
635
|
+
const octaves = [];
|
|
636
|
+
let currentOctave = initialOctave ? { dis: initialOctave.dis, disPlace: initialOctave.disPlace, startId: initialOctave.startId } : null;
|
|
637
|
+
let pendingOttava = null; // Track ottava to apply to next note
|
|
638
|
+
let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
|
|
639
|
+
let lastNoteId = null; // Track last note id for ending ottava spans
|
|
640
|
+
let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
|
|
641
|
+
// Track slur spans - slurs must be encoded as control events in MEI
|
|
642
|
+
const slurs = [];
|
|
643
|
+
let currentSlur = initialSlur ? { startId: initialSlur } : null;
|
|
644
|
+
// Track arpeggio refs
|
|
645
|
+
const arpeggios = [];
|
|
646
|
+
// Track ornament refs
|
|
647
|
+
const fermatas = [];
|
|
648
|
+
const trills = [];
|
|
649
|
+
const mordents = [];
|
|
650
|
+
const turns = [];
|
|
651
|
+
const dynamics = [];
|
|
652
|
+
const fingerings = [];
|
|
653
|
+
const navigations = [];
|
|
654
|
+
const harmonies = [];
|
|
655
|
+
const barlines = [];
|
|
656
|
+
const markups = [];
|
|
657
|
+
// Track current stem direction from context changes
|
|
658
|
+
let currentStemDirection = undefined;
|
|
659
|
+
// Track current staff for cross-staff notation
|
|
660
|
+
let currentStaff = voice.staff || 1;
|
|
661
|
+
// Track pending tie pitches (for tie="t" on next note) - initialized from previous measure
|
|
662
|
+
let pendingTiePitches = [...initialTiePitches];
|
|
663
|
+
// Helper to check if pitches match for tie continuation
|
|
664
|
+
const pitchesMatch = (p1, p2) => {
|
|
665
|
+
if (p1.length !== p2.length)
|
|
666
|
+
return false;
|
|
667
|
+
for (let i = 0; i < p1.length; i++) {
|
|
668
|
+
if (p1[i].phonet !== p2[i].phonet || p1[i].octave !== p2[i].octave)
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
return true;
|
|
672
|
+
};
|
|
673
|
+
for (const event of voice.events) {
|
|
674
|
+
// Check for beam start/end in this event (including inside tuplets)
|
|
675
|
+
const { beamStart, beamEnd } = getEventBeamMarks(event);
|
|
676
|
+
// Open beam element if beam starts
|
|
677
|
+
if (beamStart && !beamElementOpen) {
|
|
678
|
+
xml += `${baseIndent}<beam xml:id="${generateId('beam')}">\n`;
|
|
679
|
+
beamElementOpen = true;
|
|
680
|
+
}
|
|
681
|
+
const currentIndent = beamElementOpen ? baseIndent + ' ' : baseIndent;
|
|
682
|
+
switch (event.type) {
|
|
683
|
+
case 'note': {
|
|
684
|
+
const noteEvent = event;
|
|
685
|
+
// Check if this note should have tie="t" (matches pending tie)
|
|
686
|
+
const tieEnd = pendingTiePitches.length > 0 && pitchesMatch(pendingTiePitches, noteEvent.pitches);
|
|
687
|
+
// If there's a pending ottava, apply it BEFORE encoding the note
|
|
688
|
+
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
689
|
+
currentOttavaShift = pendingOttava; // Apply the shift for this note
|
|
690
|
+
}
|
|
691
|
+
// For cross-staff notation: set note's staff to currentStaff if different from voice.staff
|
|
692
|
+
const effectiveNoteEvent = currentStaff !== voice.staff
|
|
693
|
+
? { ...noteEvent, staff: currentStaff }
|
|
694
|
+
: noteEvent;
|
|
695
|
+
const result = noteEventToMEI(effectiveNoteEvent, currentIndent, voice.staff, tieEnd, currentStemDirection, keyFifths, currentOttavaShift);
|
|
696
|
+
xml += result.xml;
|
|
697
|
+
lastNoteId = result.elementId;
|
|
698
|
+
// If there's a pending ottava, start the span on this note
|
|
699
|
+
if (pendingOttava !== null && pendingOttava !== 0) {
|
|
700
|
+
const dis = Math.abs(pendingOttava) === 2 ? 15 : 8;
|
|
701
|
+
const disPlace = pendingOttava > 0 ? 'above' : 'below';
|
|
702
|
+
// Close existing span first if it has a different value
|
|
703
|
+
if (currentOctave && (currentOctave.dis !== dis || currentOctave.disPlace !== disPlace)) {
|
|
704
|
+
// Different value - close the old span
|
|
705
|
+
// Use the lastNoteId from before this note (which we saved before processing)
|
|
706
|
+
// Note: The span from previous measure will be closed by encodeMeasure
|
|
707
|
+
currentOctave = null;
|
|
708
|
+
}
|
|
709
|
+
// Start new span if we don't already have one with the same value
|
|
710
|
+
if (!currentOctave) {
|
|
711
|
+
currentOctave = { dis, disPlace, startId: result.elementId };
|
|
712
|
+
}
|
|
713
|
+
pendingOttava = null;
|
|
714
|
+
}
|
|
715
|
+
// Update pending tie pitches
|
|
716
|
+
if (result.hasTieStart) {
|
|
717
|
+
pendingTiePitches = result.pitches;
|
|
718
|
+
}
|
|
719
|
+
else if (tieEnd) {
|
|
720
|
+
pendingTiePitches = [];
|
|
721
|
+
}
|
|
722
|
+
// Track hairpin spans
|
|
723
|
+
if (result.hairpin === 'crescStart') {
|
|
724
|
+
currentHairpin = { form: 'cres', startId: result.elementId };
|
|
725
|
+
}
|
|
726
|
+
else if (result.hairpin === 'dimStart') {
|
|
727
|
+
currentHairpin = { form: 'dim', startId: result.elementId };
|
|
728
|
+
}
|
|
729
|
+
else if (result.hairpin === 'end' && currentHairpin) {
|
|
730
|
+
hairpins.push({
|
|
731
|
+
form: currentHairpin.form,
|
|
732
|
+
startId: currentHairpin.startId,
|
|
733
|
+
endId: result.elementId,
|
|
734
|
+
});
|
|
735
|
+
currentHairpin = null;
|
|
736
|
+
}
|
|
737
|
+
// Track pedal marks (each is independent)
|
|
738
|
+
if (result.pedal === 'down' || result.pedal === 'up') {
|
|
739
|
+
pedals.push({
|
|
740
|
+
startId: result.elementId,
|
|
741
|
+
dir: result.pedal,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
// Track slur spans - end must be processed before start
|
|
745
|
+
// in case a note ends one slur and starts another
|
|
746
|
+
if (result.slurEnd && currentSlur) {
|
|
747
|
+
slurs.push({
|
|
748
|
+
startId: currentSlur.startId,
|
|
749
|
+
endId: result.elementId,
|
|
750
|
+
});
|
|
751
|
+
currentSlur = null;
|
|
752
|
+
}
|
|
753
|
+
if (result.slurStart) {
|
|
754
|
+
currentSlur = { startId: result.elementId };
|
|
755
|
+
}
|
|
756
|
+
// Track arpeggio refs
|
|
757
|
+
if (result.arpeggio) {
|
|
758
|
+
arpeggios.push({ plist: result.elementId });
|
|
759
|
+
}
|
|
760
|
+
// Track ornament refs (fermata, trill, mordent, turn)
|
|
761
|
+
if (result.fermata) {
|
|
762
|
+
fermatas.push({
|
|
763
|
+
startid: result.elementId,
|
|
764
|
+
shape: result.fermata === 'short' ? 'angular' : undefined,
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
if (result.trill) {
|
|
768
|
+
trills.push({ startid: result.elementId });
|
|
769
|
+
}
|
|
770
|
+
if (result.mordent) {
|
|
771
|
+
mordents.push({
|
|
772
|
+
startid: result.elementId,
|
|
773
|
+
form: result.mordent === 'upper' ? 'upper' : undefined,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (result.turn) {
|
|
777
|
+
turns.push({ startid: result.elementId });
|
|
778
|
+
}
|
|
779
|
+
if (result.dynamic) {
|
|
780
|
+
dynamics.push({ startid: result.elementId, label: result.dynamic });
|
|
781
|
+
}
|
|
782
|
+
// Track fingerings
|
|
783
|
+
for (const fing of result.fingerings) {
|
|
784
|
+
fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
|
|
785
|
+
}
|
|
786
|
+
// Track markups from note marks
|
|
787
|
+
for (const mkup of result.markups) {
|
|
788
|
+
markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
|
|
789
|
+
}
|
|
790
|
+
// Track navigation marks
|
|
791
|
+
if (result.navigation) {
|
|
792
|
+
navigations.push({ type: result.navigation });
|
|
793
|
+
}
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
case 'rest':
|
|
797
|
+
xml += restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
|
|
798
|
+
break;
|
|
799
|
+
case 'tuplet': {
|
|
800
|
+
// Tuplet can be nested inside beam in MEI: <beam><tuplet>...</tuplet></beam>
|
|
801
|
+
// Pass beamElementOpen to tuplet so it knows not to create its own beam
|
|
802
|
+
const tupletResult = tupletEventToMEI(event, currentIndent, voice.staff, keyFifths, currentStaff, currentOttavaShift, beamElementOpen);
|
|
803
|
+
xml += tupletResult.xml;
|
|
804
|
+
// Process slur ends first (to close any pending slurs from before this tuplet)
|
|
805
|
+
for (const endId of tupletResult.slurEnds) {
|
|
806
|
+
if (currentSlur) {
|
|
807
|
+
slurs.push({
|
|
808
|
+
startId: currentSlur.startId,
|
|
809
|
+
endId: endId,
|
|
810
|
+
});
|
|
811
|
+
currentSlur = null;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
// Then process slur starts (to open new slurs)
|
|
815
|
+
for (const startId of tupletResult.slurStarts) {
|
|
816
|
+
currentSlur = { startId };
|
|
817
|
+
}
|
|
818
|
+
// Collect other control events from tuplet
|
|
819
|
+
dynamics.push(...tupletResult.dynamics);
|
|
820
|
+
fermatas.push(...tupletResult.fermatas);
|
|
821
|
+
trills.push(...tupletResult.trills);
|
|
822
|
+
mordents.push(...tupletResult.mordents);
|
|
823
|
+
turns.push(...tupletResult.turns);
|
|
824
|
+
arpeggios.push(...tupletResult.arpeggios);
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
case 'tremolo':
|
|
828
|
+
xml += tremoloEventToMEI(event, currentIndent, keyFifths, currentOttavaShift);
|
|
829
|
+
break;
|
|
830
|
+
case 'context': {
|
|
831
|
+
const ctx = event;
|
|
832
|
+
// Check for clef changes - emit <clef> element only if different from current
|
|
833
|
+
if (ctx.clef && ctx.clef !== currentClef) {
|
|
834
|
+
const clefInfo = CLEF_SHAPES[ctx.clef] || CLEF_SHAPES.treble;
|
|
835
|
+
xml += `${currentIndent}<clef xml:id="${generateId('clef')}" shape="${clefInfo.shape}" line="${clefInfo.line}" />\n`;
|
|
836
|
+
currentClef = ctx.clef;
|
|
837
|
+
}
|
|
838
|
+
// Check for ottava changes
|
|
839
|
+
if (ctx.ottava !== undefined) {
|
|
840
|
+
if (ctx.ottava === 0) {
|
|
841
|
+
// End current ottava span
|
|
842
|
+
if (currentOctave && lastNoteId) {
|
|
843
|
+
octaves.push({
|
|
844
|
+
dis: currentOctave.dis,
|
|
845
|
+
disPlace: currentOctave.disPlace,
|
|
846
|
+
startId: currentOctave.startId,
|
|
847
|
+
endId: lastNoteId,
|
|
848
|
+
});
|
|
849
|
+
currentOctave = null;
|
|
850
|
+
ottavaExplicitlyClosed = true; // Mark that we explicitly closed the span
|
|
851
|
+
}
|
|
852
|
+
// Note: if no lastNoteId (e.g., at measure start), keep currentOctave alive
|
|
853
|
+
// It may be continued by a subsequent ottava command with the same value
|
|
854
|
+
currentOttavaShift = 0; // Reset the shift (will be restored if continued)
|
|
855
|
+
}
|
|
856
|
+
else {
|
|
857
|
+
// Check if this continues an existing span (same value)
|
|
858
|
+
const dis = Math.abs(ctx.ottava) === 2 ? 15 : 8;
|
|
859
|
+
const disPlace = ctx.ottava > 0 ? 'above' : 'below';
|
|
860
|
+
if (currentOctave && currentOctave.dis === dis && currentOctave.disPlace === disPlace) {
|
|
861
|
+
// Continuation - restore the shift but don't change the span
|
|
862
|
+
currentOttavaShift = ctx.ottava;
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
// Different value - start new ottava span (will be applied to next note)
|
|
866
|
+
// If there's an existing span with different value, it will be closed when the note is processed
|
|
867
|
+
pendingOttava = ctx.ottava;
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
// Check for stem direction changes
|
|
872
|
+
if (ctx.stemDirection !== undefined) {
|
|
873
|
+
currentStemDirection = ctx.stemDirection;
|
|
874
|
+
}
|
|
875
|
+
// Check for staff changes (cross-staff notation)
|
|
876
|
+
if (ctx.staff !== undefined) {
|
|
877
|
+
currentStaff = ctx.staff;
|
|
878
|
+
}
|
|
879
|
+
// Other context changes are handled at measure level
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
case 'pitchReset':
|
|
883
|
+
// Pitch reset events are only used during pitch resolution in the parser.
|
|
884
|
+
// They don't produce any MEI output - just skip them.
|
|
885
|
+
break;
|
|
886
|
+
case 'barline':
|
|
887
|
+
barlines.push({ style: event.style });
|
|
888
|
+
break;
|
|
889
|
+
case 'harmony':
|
|
890
|
+
// Harmony needs a note ID to attach to - use the last note if available
|
|
891
|
+
if (lastNoteId) {
|
|
892
|
+
harmonies.push({ startid: lastNoteId, text: event.text });
|
|
893
|
+
}
|
|
894
|
+
break;
|
|
895
|
+
case 'markup':
|
|
896
|
+
// Markup needs a note ID to attach to - use the last note if available
|
|
897
|
+
if (lastNoteId) {
|
|
898
|
+
const mkupEvent = event;
|
|
899
|
+
markups.push({
|
|
900
|
+
startid: lastNoteId,
|
|
901
|
+
content: mkupEvent.content,
|
|
902
|
+
placement: mkupEvent.placement,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
// Close beam element if beam ends
|
|
908
|
+
if (beamEnd && beamElementOpen) {
|
|
909
|
+
xml += `${baseIndent}</beam>\n`;
|
|
910
|
+
beamElementOpen = false;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
// Close any unclosed beam
|
|
914
|
+
if (beamElementOpen) {
|
|
915
|
+
xml += `${baseIndent}</beam>\n`;
|
|
916
|
+
}
|
|
917
|
+
// Don't close ottava span at measure end - it may continue in the next measure
|
|
918
|
+
// Build pending octave state to return
|
|
919
|
+
const pendingOctave = currentOctave
|
|
920
|
+
? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift }
|
|
921
|
+
: null;
|
|
922
|
+
xml += `${indent}</layer>\n`;
|
|
923
|
+
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: currentSlur?.startId || null, pendingHairpin: currentHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift };
|
|
924
|
+
};
|
|
925
|
+
// Encode a staff
|
|
926
|
+
const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
|
|
927
|
+
const staffId = generateId("staff");
|
|
928
|
+
let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
|
|
929
|
+
const allHairpins = [];
|
|
930
|
+
const allPedals = [];
|
|
931
|
+
const allOctaves = [];
|
|
932
|
+
const allSlurs = [];
|
|
933
|
+
const allArpeggios = [];
|
|
934
|
+
const allFermatas = [];
|
|
935
|
+
const allTrills = [];
|
|
936
|
+
const allMordents = [];
|
|
937
|
+
const allTurns = [];
|
|
938
|
+
const allDynamics = [];
|
|
939
|
+
const allFingerings = [];
|
|
940
|
+
const allNavigations = [];
|
|
941
|
+
const allHarmonies = [];
|
|
942
|
+
const allBarlines = [];
|
|
943
|
+
const allMarkups = [];
|
|
944
|
+
const pendingTies = {};
|
|
945
|
+
const pendingSlurs = {};
|
|
946
|
+
const pendingHairpins = {};
|
|
947
|
+
const pendingOctaves = {};
|
|
948
|
+
const ottavaExplicitlyClosed = {};
|
|
949
|
+
const lastNoteIds = {};
|
|
950
|
+
let endingClef = initialClef;
|
|
951
|
+
if (voices.length === 0) {
|
|
952
|
+
xml += `${indent} <layer xml:id="${generateId('layer')}" n="1" />\n`;
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
voices.forEach((voice, vi) => {
|
|
956
|
+
const layerN = vi + 1;
|
|
957
|
+
const tieKey = `${staffN}-${layerN}`;
|
|
958
|
+
const initialTies = tieState[tieKey] || [];
|
|
959
|
+
const initialSlur = slurState[tieKey] || null;
|
|
960
|
+
const initialHairpin = hairpinState[tieKey] || null;
|
|
961
|
+
const initialOctave = ottavaState[tieKey] || null;
|
|
962
|
+
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
|
|
963
|
+
xml += result.xml;
|
|
964
|
+
allHairpins.push(...result.hairpins);
|
|
965
|
+
allPedals.push(...result.pedals);
|
|
966
|
+
allOctaves.push(...result.octaves);
|
|
967
|
+
allSlurs.push(...result.slurs);
|
|
968
|
+
allArpeggios.push(...result.arpeggios);
|
|
969
|
+
allFermatas.push(...result.fermatas);
|
|
970
|
+
allTrills.push(...result.trills);
|
|
971
|
+
allMordents.push(...result.mordents);
|
|
972
|
+
allTurns.push(...result.turns);
|
|
973
|
+
allDynamics.push(...result.dynamics);
|
|
974
|
+
allFingerings.push(...result.fingerings);
|
|
975
|
+
allNavigations.push(...result.navigations);
|
|
976
|
+
allHarmonies.push(...result.harmonies);
|
|
977
|
+
allBarlines.push(...result.barlines);
|
|
978
|
+
allMarkups.push(...result.markups);
|
|
979
|
+
// Track pending ties for this layer
|
|
980
|
+
if (result.pendingTiePitches.length > 0) {
|
|
981
|
+
pendingTies[tieKey] = result.pendingTiePitches;
|
|
982
|
+
}
|
|
983
|
+
// Track pending slurs for this layer
|
|
984
|
+
if (result.pendingSlur) {
|
|
985
|
+
pendingSlurs[tieKey] = result.pendingSlur;
|
|
986
|
+
}
|
|
987
|
+
// Track pending hairpins for this layer
|
|
988
|
+
if (result.pendingHairpin) {
|
|
989
|
+
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
990
|
+
}
|
|
991
|
+
// Track pending ottava spans for this layer
|
|
992
|
+
if (result.pendingOctave) {
|
|
993
|
+
pendingOctaves[tieKey] = result.pendingOctave;
|
|
994
|
+
}
|
|
995
|
+
// Track if ottava was explicitly closed in this layer
|
|
996
|
+
if (result.ottavaExplicitlyClosed) {
|
|
997
|
+
ottavaExplicitlyClosed[tieKey] = true;
|
|
998
|
+
}
|
|
999
|
+
// Track last note IDs for this layer (for closing ottava spans)
|
|
1000
|
+
lastNoteIds[tieKey] = result.lastNoteId;
|
|
1001
|
+
// Track ending clef for cross-measure tracking
|
|
1002
|
+
if (result.endingClef) {
|
|
1003
|
+
endingClef = result.endingClef;
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
xml += `${indent}</staff>\n`;
|
|
1008
|
+
return {
|
|
1009
|
+
xml,
|
|
1010
|
+
hairpins: allHairpins,
|
|
1011
|
+
pedals: allPedals,
|
|
1012
|
+
octaves: allOctaves,
|
|
1013
|
+
slurs: allSlurs,
|
|
1014
|
+
arpeggios: allArpeggios,
|
|
1015
|
+
fermatas: allFermatas,
|
|
1016
|
+
trills: allTrills,
|
|
1017
|
+
mordents: allMordents,
|
|
1018
|
+
turns: allTurns,
|
|
1019
|
+
dynamics: allDynamics,
|
|
1020
|
+
fingerings: allFingerings,
|
|
1021
|
+
navigations: allNavigations,
|
|
1022
|
+
harmonies: allHarmonies,
|
|
1023
|
+
barlines: allBarlines,
|
|
1024
|
+
markups: allMarkups,
|
|
1025
|
+
pendingTies,
|
|
1026
|
+
pendingSlurs,
|
|
1027
|
+
pendingHairpins,
|
|
1028
|
+
pendingOctaves,
|
|
1029
|
+
ottavaExplicitlyClosed,
|
|
1030
|
+
lastNoteIds,
|
|
1031
|
+
endingClef,
|
|
1032
|
+
};
|
|
1033
|
+
};
|
|
1034
|
+
// Tempo text to BPM mapping (approximate values based on musical convention)
|
|
1035
|
+
const TEMPO_TEXT_TO_BPM = {
|
|
1036
|
+
// Very slow
|
|
1037
|
+
'grave': 35,
|
|
1038
|
+
'largo': 50,
|
|
1039
|
+
'larghetto': 63,
|
|
1040
|
+
'lento': 52,
|
|
1041
|
+
'adagio': 70,
|
|
1042
|
+
// Slow to moderate
|
|
1043
|
+
'andante': 92,
|
|
1044
|
+
'andantino': 96,
|
|
1045
|
+
'moderato': 114,
|
|
1046
|
+
// Fast
|
|
1047
|
+
'allegretto': 116,
|
|
1048
|
+
'allegro': 138,
|
|
1049
|
+
'vivace': 166,
|
|
1050
|
+
'presto': 184,
|
|
1051
|
+
'prestissimo': 208,
|
|
1052
|
+
};
|
|
1053
|
+
// Infer BPM from tempo text
|
|
1054
|
+
const inferBpmFromText = (text) => {
|
|
1055
|
+
const lowerText = text.toLowerCase();
|
|
1056
|
+
for (const [keyword, bpm] of Object.entries(TEMPO_TEXT_TO_BPM)) {
|
|
1057
|
+
if (lowerText.includes(keyword)) {
|
|
1058
|
+
return bpm;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return undefined;
|
|
1062
|
+
};
|
|
1063
|
+
// Generate tempo element
|
|
1064
|
+
const generateTempoElement = (tempo, indent, staff = 1) => {
|
|
1065
|
+
let attrs = `xml:id="${generateId('tempo')}" tstamp="1" staff="${staff}"`;
|
|
1066
|
+
// Determine BPM: use explicit value or infer from text
|
|
1067
|
+
let bpm = tempo.bpm;
|
|
1068
|
+
if (!bpm && tempo.text) {
|
|
1069
|
+
bpm = inferBpmFromText(tempo.text);
|
|
1070
|
+
}
|
|
1071
|
+
// Add BPM if available
|
|
1072
|
+
if (bpm) {
|
|
1073
|
+
attrs += ` midi.bpm="${bpm}"`;
|
|
1074
|
+
if (tempo.beat) {
|
|
1075
|
+
attrs += ` mm="${bpm}" mm.unit="${tempo.beat.division}"`;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// Generate content
|
|
1079
|
+
let content = '';
|
|
1080
|
+
if (tempo.text) {
|
|
1081
|
+
content = escapeXml(tempo.text);
|
|
1082
|
+
}
|
|
1083
|
+
if (tempo.beat && tempo.bpm) {
|
|
1084
|
+
const beatSymbol = tempo.beat.division === 4 ? '♩' : tempo.beat.division === 2 ? '𝅗𝅥' : '♪';
|
|
1085
|
+
if (content)
|
|
1086
|
+
content += ' ';
|
|
1087
|
+
content += `${beatSymbol} = ${tempo.bpm}`;
|
|
1088
|
+
}
|
|
1089
|
+
if (content) {
|
|
1090
|
+
return `${indent}<tempo ${attrs}>${content}</tempo>\n`;
|
|
1091
|
+
}
|
|
1092
|
+
return `${indent}<tempo ${attrs} />\n`;
|
|
1093
|
+
};
|
|
1094
|
+
// Barline style to MEI @right attribute mapping
|
|
1095
|
+
const BARLINE_TO_MEI = {
|
|
1096
|
+
'|': 'single',
|
|
1097
|
+
'||': 'dbl',
|
|
1098
|
+
'|.': 'end',
|
|
1099
|
+
'.|:': 'rptstart',
|
|
1100
|
+
':|.': 'rptend',
|
|
1101
|
+
':..:|': 'rptboth',
|
|
1102
|
+
':..:': 'rptboth',
|
|
1103
|
+
};
|
|
1104
|
+
// Encode a measure
|
|
1105
|
+
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1106
|
+
const encodeMeasure = (measure, measureN, indent, totalStaves, tieState, slurState, hairpinState, ottavaState, keyFifths = 0, partInfos = [], clefState = {}) => {
|
|
1107
|
+
const measureId = generateId("measure");
|
|
1108
|
+
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
1109
|
+
const allHairpins = [];
|
|
1110
|
+
const allPedals = [];
|
|
1111
|
+
const allOctaves = [];
|
|
1112
|
+
const allSlurs = [];
|
|
1113
|
+
const allArpeggios = [];
|
|
1114
|
+
const allFermatas = [];
|
|
1115
|
+
const allTrills = [];
|
|
1116
|
+
const allMordents = [];
|
|
1117
|
+
const allTurns = [];
|
|
1118
|
+
const allDynamics = [];
|
|
1119
|
+
const allFingerings = [];
|
|
1120
|
+
const allNavigations = [];
|
|
1121
|
+
const allHarmonies = [];
|
|
1122
|
+
const allBarlines = [];
|
|
1123
|
+
const allMarkups = [];
|
|
1124
|
+
// Extract tempo from context changes (track which staff it came from)
|
|
1125
|
+
let measureTempo;
|
|
1126
|
+
let tempoStaff = 1;
|
|
1127
|
+
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
1128
|
+
const part = measure.parts[pi];
|
|
1129
|
+
const partOffset = partInfos[pi]?.staffOffset || 0;
|
|
1130
|
+
for (const voice of part.voices) {
|
|
1131
|
+
const localStaff = voice.staff || 1;
|
|
1132
|
+
const globalStaff = partOffset + localStaff;
|
|
1133
|
+
for (const event of voice.events) {
|
|
1134
|
+
if (event.type === 'context') {
|
|
1135
|
+
const ctx = event;
|
|
1136
|
+
if (ctx.tempo) {
|
|
1137
|
+
measureTempo = ctx.tempo;
|
|
1138
|
+
tempoStaff = globalStaff;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
// Group voices by global staff (local staff + part offset)
|
|
1145
|
+
const voicesByStaff = {};
|
|
1146
|
+
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
1147
|
+
const part = measure.parts[pi];
|
|
1148
|
+
const partOffset = partInfos[pi]?.staffOffset || 0;
|
|
1149
|
+
for (const voice of part.voices) {
|
|
1150
|
+
const localStaff = voice.staff || 1;
|
|
1151
|
+
const globalStaff = partOffset + localStaff;
|
|
1152
|
+
if (!voicesByStaff[globalStaff]) {
|
|
1153
|
+
voicesByStaff[globalStaff] = [];
|
|
1154
|
+
}
|
|
1155
|
+
voicesByStaff[globalStaff].push(voice);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
// Encode each staff, passing and updating tie state, slur state, hairpin state, ottava state and clef state
|
|
1159
|
+
for (let si = 1; si <= totalStaves; si++) {
|
|
1160
|
+
const voices = voicesByStaff[si] || [];
|
|
1161
|
+
const initialClef = clefState[si];
|
|
1162
|
+
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
|
|
1163
|
+
staffContent += result.xml;
|
|
1164
|
+
allHairpins.push(...result.hairpins);
|
|
1165
|
+
allPedals.push(...result.pedals);
|
|
1166
|
+
allOctaves.push(...result.octaves);
|
|
1167
|
+
allSlurs.push(...result.slurs);
|
|
1168
|
+
allArpeggios.push(...result.arpeggios);
|
|
1169
|
+
allFermatas.push(...result.fermatas);
|
|
1170
|
+
allTrills.push(...result.trills);
|
|
1171
|
+
allMordents.push(...result.mordents);
|
|
1172
|
+
allTurns.push(...result.turns);
|
|
1173
|
+
allDynamics.push(...result.dynamics);
|
|
1174
|
+
allFingerings.push(...result.fingerings);
|
|
1175
|
+
allNavigations.push(...result.navigations);
|
|
1176
|
+
allHarmonies.push(...result.harmonies);
|
|
1177
|
+
allBarlines.push(...result.barlines);
|
|
1178
|
+
allMarkups.push(...result.markups);
|
|
1179
|
+
// Update tie state with pending ties from this staff
|
|
1180
|
+
Object.assign(tieState, result.pendingTies);
|
|
1181
|
+
// Update slur state with pending slurs from this staff
|
|
1182
|
+
Object.assign(slurState, result.pendingSlurs);
|
|
1183
|
+
// Update hairpin state with pending hairpins from this staff
|
|
1184
|
+
Object.assign(hairpinState, result.pendingHairpins);
|
|
1185
|
+
// Update ottava state with pending octaves from this staff
|
|
1186
|
+
// Also handle closing spans when ottava ends
|
|
1187
|
+
const currentStaffPrefix = `${si}-`;
|
|
1188
|
+
for (const [key, pending] of Object.entries(result.pendingOctaves)) {
|
|
1189
|
+
if (pending) {
|
|
1190
|
+
// Check if this is a continuation or a new span
|
|
1191
|
+
const prevPending = ottavaState[key];
|
|
1192
|
+
if (prevPending && prevPending.shift === pending.shift) {
|
|
1193
|
+
// Same ottava value continues - keep the original startId
|
|
1194
|
+
ottavaState[key] = { ...pending, startId: prevPending.startId };
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
// Different ottava value - close the old span first if exists
|
|
1198
|
+
if (prevPending) {
|
|
1199
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1200
|
+
if (lastNoteId) {
|
|
1201
|
+
allOctaves.push({
|
|
1202
|
+
dis: prevPending.dis,
|
|
1203
|
+
disPlace: prevPending.disPlace,
|
|
1204
|
+
startId: prevPending.startId,
|
|
1205
|
+
endId: lastNoteId,
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
// Start new span
|
|
1210
|
+
ottavaState[key] = pending;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
// For layers in this staff that had pending octaves but didn't in this measure, close the spans
|
|
1215
|
+
for (const [key, pending] of Object.entries(ottavaState)) {
|
|
1216
|
+
// Only process keys for the current staff
|
|
1217
|
+
if (key.startsWith(currentStaffPrefix) && pending && !result.pendingOctaves[key]) {
|
|
1218
|
+
// Check if the span was already explicitly closed in encodeLayer
|
|
1219
|
+
// If so, don't generate another span (it was already pushed to octaves in encodeLayer)
|
|
1220
|
+
if (!result.ottavaExplicitlyClosed[key]) {
|
|
1221
|
+
// Ottava ended without explicit close - generate the closing span
|
|
1222
|
+
const lastNoteId = result.lastNoteIds[key];
|
|
1223
|
+
if (lastNoteId) {
|
|
1224
|
+
allOctaves.push({
|
|
1225
|
+
dis: pending.dis,
|
|
1226
|
+
disPlace: pending.disPlace,
|
|
1227
|
+
startId: pending.startId,
|
|
1228
|
+
endId: lastNoteId,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
delete ottavaState[key];
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
// Update clef state with ending clef from this staff
|
|
1236
|
+
if (result.endingClef) {
|
|
1237
|
+
clefState[si] = result.endingClef;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
// Generate tempo element if present
|
|
1241
|
+
if (measureTempo) {
|
|
1242
|
+
staffContent += generateTempoElement(measureTempo, indent + ' ', tempoStaff);
|
|
1243
|
+
}
|
|
1244
|
+
// Generate hairpin control events
|
|
1245
|
+
for (const hp of allHairpins) {
|
|
1246
|
+
staffContent += `${indent} <hairpin xml:id="${generateId('hairpin')}" form="${hp.form}" startid="#${hp.startId}" endid="#${hp.endId}" />\n`;
|
|
1247
|
+
}
|
|
1248
|
+
// Generate pedal control events (each mark is independent)
|
|
1249
|
+
for (const ped of allPedals) {
|
|
1250
|
+
staffContent += `${indent} <pedal xml:id="${generateId('pedal')}" dir="${ped.dir}" startid="#${ped.startId}" />\n`;
|
|
1251
|
+
}
|
|
1252
|
+
// Generate octave control events
|
|
1253
|
+
for (const oct of allOctaves) {
|
|
1254
|
+
staffContent += `${indent} <octave xml:id="${generateId('octave')}" dis="${oct.dis}" dis.place="${oct.disPlace}" startid="#${oct.startId}" endid="#${oct.endId}" />\n`;
|
|
1255
|
+
}
|
|
1256
|
+
// Generate slur control events
|
|
1257
|
+
for (const sl of allSlurs) {
|
|
1258
|
+
staffContent += `${indent} <slur xml:id="${generateId('slur')}" startid="#${sl.startId}" endid="#${sl.endId}" />\n`;
|
|
1259
|
+
}
|
|
1260
|
+
// Generate arpeggio control events
|
|
1261
|
+
for (const arp of allArpeggios) {
|
|
1262
|
+
staffContent += `${indent} <arpeg xml:id="${generateId('arpeg')}" plist="#${arp.plist}" />\n`;
|
|
1263
|
+
}
|
|
1264
|
+
// Generate fermata control events
|
|
1265
|
+
for (const ferm of allFermatas) {
|
|
1266
|
+
const shapeAttr = ferm.shape ? ` shape="${ferm.shape}"` : '';
|
|
1267
|
+
staffContent += `${indent} <fermata xml:id="${generateId('fermata')}" startid="#${ferm.startid}"${shapeAttr} />\n`;
|
|
1268
|
+
}
|
|
1269
|
+
// Generate trill control events
|
|
1270
|
+
for (const tr of allTrills) {
|
|
1271
|
+
staffContent += `${indent} <trill xml:id="${generateId('trill')}" startid="#${tr.startid}" />\n`;
|
|
1272
|
+
}
|
|
1273
|
+
// Generate mordent control events
|
|
1274
|
+
for (const mord of allMordents) {
|
|
1275
|
+
const formAttr = mord.form ? ` form="${mord.form}"` : '';
|
|
1276
|
+
staffContent += `${indent} <mordent xml:id="${generateId('mordent')}" startid="#${mord.startid}"${formAttr} />\n`;
|
|
1277
|
+
}
|
|
1278
|
+
// Generate turn control events
|
|
1279
|
+
for (const tu of allTurns) {
|
|
1280
|
+
staffContent += `${indent} <turn xml:id="${generateId('turn')}" startid="#${tu.startid}" />\n`;
|
|
1281
|
+
}
|
|
1282
|
+
// Generate dynamic control events
|
|
1283
|
+
for (const dyn of allDynamics) {
|
|
1284
|
+
staffContent += `${indent} <dynam xml:id="${generateId('dynam')}" startid="#${dyn.startid}">${dyn.label}</dynam>\n`;
|
|
1285
|
+
}
|
|
1286
|
+
// Generate fingering control events
|
|
1287
|
+
for (const fing of allFingerings) {
|
|
1288
|
+
const placeAttr = fing.placement ? ` place="${fing.placement}"` : '';
|
|
1289
|
+
staffContent += `${indent} <fing xml:id="${generateId('fing')}" startid="#${fing.startid}"${placeAttr}>${fing.finger}</fing>\n`;
|
|
1290
|
+
}
|
|
1291
|
+
// Generate dir elements for navigation marks (coda, segno)
|
|
1292
|
+
for (const nav of allNavigations) {
|
|
1293
|
+
// Use <dir> element with appropriate glyph
|
|
1294
|
+
const glyph = nav.type === 'coda' ? '𝄌' : '𝄋'; // Unicode coda/segno symbols
|
|
1295
|
+
staffContent += `${indent} <dir xml:id="${generateId('dir')}" tstamp="1">${glyph}</dir>\n`;
|
|
1296
|
+
}
|
|
1297
|
+
// Generate harm elements for chord symbols
|
|
1298
|
+
for (const harm of allHarmonies) {
|
|
1299
|
+
staffContent += `${indent} <harm xml:id="${generateId('harm')}" startid="#${harm.startid}">${escapeXml(harm.text)}</harm>\n`;
|
|
1300
|
+
}
|
|
1301
|
+
// Generate dir elements for markups
|
|
1302
|
+
for (const mkup of allMarkups) {
|
|
1303
|
+
const placeAttr = mkup.placement ? ` place="${mkup.placement}"` : '';
|
|
1304
|
+
staffContent += `${indent} <dir xml:id="${generateId('dir')}" startid="#${mkup.startid}"${placeAttr}>${escapeXml(mkup.content)}</dir>\n`;
|
|
1305
|
+
}
|
|
1306
|
+
// Determine barline attribute from collected barlines
|
|
1307
|
+
let barlineAttr = '';
|
|
1308
|
+
if (allBarlines.length > 0) {
|
|
1309
|
+
const lastBarline = allBarlines[allBarlines.length - 1];
|
|
1310
|
+
const meiBarline = BARLINE_TO_MEI[lastBarline.style];
|
|
1311
|
+
if (meiBarline && meiBarline !== 'single') {
|
|
1312
|
+
barlineAttr = ` right="${meiBarline}"`;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
// Build final XML with measure tag including barline
|
|
1316
|
+
let xml = `${indent}<measure xml:id="${measureId}" n="${measureN}"${barlineAttr}>\n`;
|
|
1317
|
+
xml += staffContent;
|
|
1318
|
+
xml += `${indent}</measure>\n`;
|
|
1319
|
+
return xml;
|
|
1320
|
+
};
|
|
1321
|
+
// Analyze document to get part structure
|
|
1322
|
+
const analyzePartStructure = (doc) => {
|
|
1323
|
+
// Find maximum number of parts in any measure
|
|
1324
|
+
let maxParts = 0;
|
|
1325
|
+
for (const measure of doc.measures) {
|
|
1326
|
+
maxParts = Math.max(maxParts, measure.parts.length);
|
|
1327
|
+
}
|
|
1328
|
+
// Initialize part info
|
|
1329
|
+
const partInfos = [];
|
|
1330
|
+
for (let i = 0; i < maxParts; i++) {
|
|
1331
|
+
partInfos.push({ maxStaff: 1, staffOffset: 0, clefs: {} });
|
|
1332
|
+
}
|
|
1333
|
+
// Analyze each measure to find max staff per part and clefs
|
|
1334
|
+
for (const measure of doc.measures) {
|
|
1335
|
+
for (let pi = 0; pi < measure.parts.length; pi++) {
|
|
1336
|
+
const part = measure.parts[pi];
|
|
1337
|
+
for (const voice of part.voices) {
|
|
1338
|
+
const localStaff = voice.staff || 1;
|
|
1339
|
+
partInfos[pi].maxStaff = Math.max(partInfos[pi].maxStaff, localStaff);
|
|
1340
|
+
// Get FIRST clef from context changes (for initial staffDef)
|
|
1341
|
+
for (const event of voice.events) {
|
|
1342
|
+
if (event.type === 'context') {
|
|
1343
|
+
const ctx = event;
|
|
1344
|
+
if (ctx.clef && !partInfos[pi].clefs[localStaff]) {
|
|
1345
|
+
// Only set if not already set - take the FIRST clef
|
|
1346
|
+
partInfos[pi].clefs[localStaff] = ctx.clef;
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
// Calculate staff offsets
|
|
1354
|
+
let offset = 0;
|
|
1355
|
+
for (const info of partInfos) {
|
|
1356
|
+
info.staffOffset = offset;
|
|
1357
|
+
offset += info.maxStaff;
|
|
1358
|
+
}
|
|
1359
|
+
return partInfos;
|
|
1360
|
+
};
|
|
1361
|
+
// Encode scoreDef with part groups
|
|
1362
|
+
const encodeScoreDef = (keySig, timeNum, timeDen, partInfos, indent, meterSymbol) => {
|
|
1363
|
+
const scoreDefId = generateId("scoredef");
|
|
1364
|
+
// Build meter attributes
|
|
1365
|
+
const meterSymAttr = meterSymbol ? ` meter.sym="${meterSymbol}"` : '';
|
|
1366
|
+
let xml = `${indent}<scoreDef xml:id="${scoreDefId}" key.sig="${keySig}"${meterSymAttr} meter.count="${timeNum}" meter.unit="${timeDen}">\n`;
|
|
1367
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}">\n`;
|
|
1368
|
+
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
1369
|
+
const info = partInfos[pi];
|
|
1370
|
+
// If part has multiple staves (grand staff), wrap in staffGrp with brace
|
|
1371
|
+
if (info.maxStaff > 1) {
|
|
1372
|
+
xml += `${indent} <staffGrp xml:id="${generateId("staffgrp")}" symbol="brace" bar.thru="true">\n`;
|
|
1373
|
+
for (let ls = 1; ls <= info.maxStaff; ls++) {
|
|
1374
|
+
const globalStaff = info.staffOffset + ls;
|
|
1375
|
+
const clef = info.clefs[ls] || Clef.treble;
|
|
1376
|
+
const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
|
|
1377
|
+
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
|
|
1378
|
+
}
|
|
1379
|
+
xml += `${indent} </staffGrp>\n`;
|
|
1380
|
+
}
|
|
1381
|
+
else {
|
|
1382
|
+
// Single staff part
|
|
1383
|
+
const globalStaff = info.staffOffset + 1;
|
|
1384
|
+
const clef = info.clefs[1] || Clef.treble;
|
|
1385
|
+
const clefInfo = CLEF_SHAPES[clef] || CLEF_SHAPES.treble;
|
|
1386
|
+
xml += `${indent} <staffDef xml:id="${generateId('staffdef')}" n="${globalStaff}" lines="5" clef.shape="${clefInfo.shape}" clef.line="${clefInfo.line}" />\n`;
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
xml += `${indent} </staffGrp>\n`;
|
|
1390
|
+
xml += `${indent}</scoreDef>\n`;
|
|
1391
|
+
return xml;
|
|
1392
|
+
};
|
|
1393
|
+
// === Auto-beam logic ===
|
|
1394
|
+
// Check if any NoteEvent in the document has a beam mark
|
|
1395
|
+
const docHasBeamMarks = (doc) => {
|
|
1396
|
+
for (const measure of doc.measures) {
|
|
1397
|
+
for (const part of measure.parts) {
|
|
1398
|
+
for (const voice of part.voices) {
|
|
1399
|
+
for (const event of voice.events) {
|
|
1400
|
+
if (event.type === 'note') {
|
|
1401
|
+
const note = event;
|
|
1402
|
+
if (note.marks) {
|
|
1403
|
+
for (const m of note.marks) {
|
|
1404
|
+
if (m.markType === 'beam')
|
|
1405
|
+
return true;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
else if (event.type === 'tuplet') {
|
|
1410
|
+
const tuplet = event;
|
|
1411
|
+
for (const e of tuplet.events) {
|
|
1412
|
+
if (e.type === 'note') {
|
|
1413
|
+
const note = e;
|
|
1414
|
+
if (note.marks) {
|
|
1415
|
+
for (const m of note.marks) {
|
|
1416
|
+
if (m.markType === 'beam')
|
|
1417
|
+
return true;
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
return false;
|
|
1428
|
+
};
|
|
1429
|
+
// Resolve whether auto-beam should be applied
|
|
1430
|
+
const resolveAutoBeam = (doc) => {
|
|
1431
|
+
if (doc.metadata?.autoBeam === 'off')
|
|
1432
|
+
return false;
|
|
1433
|
+
if (doc.metadata?.autoBeam === 'on')
|
|
1434
|
+
return true;
|
|
1435
|
+
// 'auto' or undefined: auto-beam if no manual beam marks exist
|
|
1436
|
+
return !docHasBeamMarks(doc);
|
|
1437
|
+
};
|
|
1438
|
+
// Compute beam group sizes in eighth-note units for a given time signature
|
|
1439
|
+
const getBeamGroups = (timeNum, timeDen) => {
|
|
1440
|
+
// Compound meters (n/8 where n is divisible by 3, and n > 3)
|
|
1441
|
+
if (timeDen === 8 && timeNum % 3 === 0 && timeNum > 3) {
|
|
1442
|
+
const groupCount = timeNum / 3;
|
|
1443
|
+
return Array(groupCount).fill(3);
|
|
1444
|
+
}
|
|
1445
|
+
// Specific common time signatures (LilyPond defaults)
|
|
1446
|
+
if (timeDen === 8 && timeNum === 3)
|
|
1447
|
+
return [3];
|
|
1448
|
+
if (timeDen === 4 && timeNum === 2)
|
|
1449
|
+
return [2, 2];
|
|
1450
|
+
if (timeDen === 4 && timeNum === 3)
|
|
1451
|
+
return [3, 3];
|
|
1452
|
+
if (timeDen === 4 && timeNum === 4)
|
|
1453
|
+
return [4, 4];
|
|
1454
|
+
if (timeDen === 2 && timeNum === 2)
|
|
1455
|
+
return [4, 4];
|
|
1456
|
+
// Generic simple meters: each beat = 8/den eighths
|
|
1457
|
+
const eighthsPerBeat = 8 / timeDen;
|
|
1458
|
+
if (eighthsPerBeat >= 1) {
|
|
1459
|
+
return Array(timeNum).fill(eighthsPerBeat);
|
|
1460
|
+
}
|
|
1461
|
+
// Fallback: one group for the whole measure
|
|
1462
|
+
const totalEighths = timeNum * 8 / timeDen;
|
|
1463
|
+
return [totalEighths];
|
|
1464
|
+
};
|
|
1465
|
+
// Calculate duration in eighth-note units
|
|
1466
|
+
const durationInEighths = (division, dots, tupletRatio) => {
|
|
1467
|
+
// Base duration in eighths: 8 / division
|
|
1468
|
+
let dur = 8 / division;
|
|
1469
|
+
// Dot multiplier: 1 + 1/2 + 1/4 + ... = 2 - 1/2^dots
|
|
1470
|
+
if (dots > 0) {
|
|
1471
|
+
dur *= (2 - Math.pow(0.5, dots));
|
|
1472
|
+
}
|
|
1473
|
+
// Tuplet ratio: multiply by num/den (LilyPond semantics)
|
|
1474
|
+
if (tupletRatio) {
|
|
1475
|
+
dur *= tupletRatio.numerator / tupletRatio.denominator;
|
|
1476
|
+
}
|
|
1477
|
+
return dur;
|
|
1478
|
+
};
|
|
1479
|
+
// Apply auto-beam to the document, mutating events' marks arrays in-place
|
|
1480
|
+
const applyAutoBeam = (doc) => {
|
|
1481
|
+
// Track time signature across measures
|
|
1482
|
+
let timeNum = 4;
|
|
1483
|
+
let timeDen = 4;
|
|
1484
|
+
// Get initial time signature
|
|
1485
|
+
if (doc.measures.length > 0 && doc.measures[0].timeSig) {
|
|
1486
|
+
timeNum = doc.measures[0].timeSig.numerator;
|
|
1487
|
+
timeDen = doc.measures[0].timeSig.denominator;
|
|
1488
|
+
}
|
|
1489
|
+
for (const measure of doc.measures) {
|
|
1490
|
+
// Update time signature if changed
|
|
1491
|
+
if (measure.timeSig) {
|
|
1492
|
+
timeNum = measure.timeSig.numerator;
|
|
1493
|
+
timeDen = measure.timeSig.denominator;
|
|
1494
|
+
}
|
|
1495
|
+
const beamGroups = getBeamGroups(timeNum, timeDen);
|
|
1496
|
+
for (const part of measure.parts) {
|
|
1497
|
+
for (const voice of part.voices) {
|
|
1498
|
+
applyAutoBeamToVoice(voice.events, beamGroups);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
// Apply auto-beam to a single voice's events
|
|
1504
|
+
const applyAutoBeamToVoice = (events, beamGroups) => {
|
|
1505
|
+
// Compute group boundary positions in eighths
|
|
1506
|
+
const groupBoundaries = [];
|
|
1507
|
+
let boundary = 0;
|
|
1508
|
+
for (const size of beamGroups) {
|
|
1509
|
+
boundary += size;
|
|
1510
|
+
groupBoundaries.push(boundary);
|
|
1511
|
+
}
|
|
1512
|
+
const totalMeasureEighths = boundary;
|
|
1513
|
+
// Collect beamable notes with their positions
|
|
1514
|
+
let position = 0;
|
|
1515
|
+
const beamableRuns = [];
|
|
1516
|
+
let currentRun = [];
|
|
1517
|
+
// Helper: find which group index a position belongs to
|
|
1518
|
+
const getGroupIndex = (pos) => {
|
|
1519
|
+
for (let i = 0; i < groupBoundaries.length; i++) {
|
|
1520
|
+
if (pos < groupBoundaries[i])
|
|
1521
|
+
return i;
|
|
1522
|
+
}
|
|
1523
|
+
return groupBoundaries.length - 1;
|
|
1524
|
+
};
|
|
1525
|
+
// Helper: flush current run into beamableRuns
|
|
1526
|
+
const flushRun = () => {
|
|
1527
|
+
if (currentRun.length >= 2) {
|
|
1528
|
+
beamableRuns.push(currentRun);
|
|
1529
|
+
}
|
|
1530
|
+
currentRun = [];
|
|
1531
|
+
};
|
|
1532
|
+
for (const event of events) {
|
|
1533
|
+
if (event.type === 'note') {
|
|
1534
|
+
const note = event;
|
|
1535
|
+
if (note.grace)
|
|
1536
|
+
continue; // skip grace notes
|
|
1537
|
+
const dur = durationInEighths(note.duration.division, note.duration.dots);
|
|
1538
|
+
if (note.duration.division >= 8) {
|
|
1539
|
+
// Beamable note
|
|
1540
|
+
const groupIdx = getGroupIndex(position);
|
|
1541
|
+
const noteEndPos = position + dur;
|
|
1542
|
+
const endGroupIdx = getGroupIndex(Math.min(noteEndPos - 0.001, totalMeasureEighths - 0.001));
|
|
1543
|
+
// Note must start and end within the same group
|
|
1544
|
+
if (groupIdx === endGroupIdx) {
|
|
1545
|
+
// Check if current run is in the same group
|
|
1546
|
+
if (currentRun.length > 0) {
|
|
1547
|
+
const lastGroupIdx = getGroupIndex(currentRun[0].position);
|
|
1548
|
+
if (lastGroupIdx !== groupIdx) {
|
|
1549
|
+
flushRun();
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
currentRun.push({ note, position });
|
|
1553
|
+
}
|
|
1554
|
+
else {
|
|
1555
|
+
// Note spans group boundary — break
|
|
1556
|
+
flushRun();
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
else {
|
|
1560
|
+
// Non-beamable note (quarter or longer) — break
|
|
1561
|
+
flushRun();
|
|
1562
|
+
}
|
|
1563
|
+
position += dur;
|
|
1564
|
+
}
|
|
1565
|
+
else if (event.type === 'rest') {
|
|
1566
|
+
const rest = event;
|
|
1567
|
+
const dur = durationInEighths(rest.duration.division, rest.duration.dots);
|
|
1568
|
+
// Rests break beam groups
|
|
1569
|
+
flushRun();
|
|
1570
|
+
position += dur;
|
|
1571
|
+
}
|
|
1572
|
+
else if (event.type === 'tuplet') {
|
|
1573
|
+
const tuplet = event;
|
|
1574
|
+
const ratio = tuplet.ratio; // LilyPond ratio: num/den
|
|
1575
|
+
// Check if all inner notes are beamable (division >= 8)
|
|
1576
|
+
const innerNotes = [];
|
|
1577
|
+
let allBeamable = true;
|
|
1578
|
+
let tupletDur = 0;
|
|
1579
|
+
for (const e of tuplet.events) {
|
|
1580
|
+
if (e.type === 'note') {
|
|
1581
|
+
const note = e;
|
|
1582
|
+
if (note.grace)
|
|
1583
|
+
continue;
|
|
1584
|
+
const dur = durationInEighths(note.duration.division, note.duration.dots, ratio);
|
|
1585
|
+
innerNotes.push({ note, dur });
|
|
1586
|
+
tupletDur += dur;
|
|
1587
|
+
if (note.duration.division < 8) {
|
|
1588
|
+
allBeamable = false;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
else if (e.type === 'rest') {
|
|
1592
|
+
allBeamable = false;
|
|
1593
|
+
const dur = durationInEighths(e.duration.division, e.duration.dots, ratio);
|
|
1594
|
+
tupletDur += dur;
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
if (allBeamable && innerNotes.length > 0) {
|
|
1598
|
+
const groupIdx = getGroupIndex(position);
|
|
1599
|
+
const endGroupIdx = getGroupIndex(Math.min(position + tupletDur - 0.001, totalMeasureEighths - 0.001));
|
|
1600
|
+
if (groupIdx === endGroupIdx) {
|
|
1601
|
+
// Tuplet fits within one group — add inner notes to current run
|
|
1602
|
+
if (currentRun.length > 0) {
|
|
1603
|
+
const lastGroupIdx = getGroupIndex(currentRun[0].position);
|
|
1604
|
+
if (lastGroupIdx !== groupIdx) {
|
|
1605
|
+
flushRun();
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
let innerPos = position;
|
|
1609
|
+
for (const { note, dur } of innerNotes) {
|
|
1610
|
+
currentRun.push({ note, position: innerPos });
|
|
1611
|
+
innerPos += dur;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
else {
|
|
1615
|
+
flushRun();
|
|
1616
|
+
}
|
|
1617
|
+
}
|
|
1618
|
+
else {
|
|
1619
|
+
flushRun();
|
|
1620
|
+
}
|
|
1621
|
+
position += tupletDur;
|
|
1622
|
+
}
|
|
1623
|
+
else if (event.type === 'context' || event.type === 'pitchReset' || event.type === 'barline' || event.type === 'harmony' || event.type === 'markup') {
|
|
1624
|
+
// Non-musical events: don't advance position, don't break beams
|
|
1625
|
+
continue;
|
|
1626
|
+
}
|
|
1627
|
+
else if (event.type === 'tremolo') {
|
|
1628
|
+
// Tremolo breaks beams
|
|
1629
|
+
const trem = event;
|
|
1630
|
+
// Total duration = count * 2 * (1/division) in whole notes
|
|
1631
|
+
// In eighths: count * 2 * (8/division)
|
|
1632
|
+
const dur = trem.count * 2 * (8 / trem.division);
|
|
1633
|
+
flushRun();
|
|
1634
|
+
position += dur;
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
// Flush any remaining run
|
|
1638
|
+
flushRun();
|
|
1639
|
+
// Apply beam marks to collected runs
|
|
1640
|
+
for (const run of beamableRuns) {
|
|
1641
|
+
const first = run[0].note;
|
|
1642
|
+
const last = run[run.length - 1].note;
|
|
1643
|
+
if (!first.marks)
|
|
1644
|
+
first.marks = [];
|
|
1645
|
+
first.marks.push({ markType: 'beam', start: true });
|
|
1646
|
+
if (!last.marks)
|
|
1647
|
+
last.marks = [];
|
|
1648
|
+
last.marks.push({ markType: 'beam', start: false });
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
// Main encode function
|
|
1652
|
+
const encode = (doc, options = {}) => {
|
|
1653
|
+
const indent = options.indent || " ";
|
|
1654
|
+
resetIdCounter();
|
|
1655
|
+
if (!doc.measures || doc.measures.length === 0) {
|
|
1656
|
+
return "";
|
|
1657
|
+
}
|
|
1658
|
+
// Analyze part structure to get staff offsets
|
|
1659
|
+
const partInfos = analyzePartStructure(doc);
|
|
1660
|
+
// Calculate total staff count
|
|
1661
|
+
const totalStaves = partInfos.reduce((sum, info) => sum + info.maxStaff, 0);
|
|
1662
|
+
// Collect initial key/time from first measure
|
|
1663
|
+
let currentKey = 0;
|
|
1664
|
+
let currentTimeNum = 4;
|
|
1665
|
+
let currentTimeDen = 4;
|
|
1666
|
+
let currentMeterSymbol = undefined;
|
|
1667
|
+
const firstMeasure = doc.measures[0];
|
|
1668
|
+
if (firstMeasure.key) {
|
|
1669
|
+
currentKey = keyToFifths(firstMeasure.key);
|
|
1670
|
+
}
|
|
1671
|
+
if (firstMeasure.timeSig) {
|
|
1672
|
+
currentTimeNum = firstMeasure.timeSig.numerator;
|
|
1673
|
+
currentTimeDen = firstMeasure.timeSig.denominator;
|
|
1674
|
+
currentMeterSymbol = firstMeasure.timeSig.symbol;
|
|
1675
|
+
}
|
|
1676
|
+
const keySig = KEY_SIGS[currentKey] || "0";
|
|
1677
|
+
// Apply auto-beam if needed (before encoding so beam marks are picked up by encodeLayer)
|
|
1678
|
+
const shouldAutoBeam = resolveAutoBeam(doc);
|
|
1679
|
+
if (shouldAutoBeam) {
|
|
1680
|
+
applyAutoBeam(doc);
|
|
1681
|
+
}
|
|
1682
|
+
// Build MEI document
|
|
1683
|
+
const xmlDecl = options.xmlDeclaration !== false
|
|
1684
|
+
? '<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
1685
|
+
: "";
|
|
1686
|
+
let mei = xmlDecl;
|
|
1687
|
+
mei += '<mei xmlns="http://www.music-encoding.org/ns/mei" meiversion="5.0">\n';
|
|
1688
|
+
mei += `${indent}<meiHead>\n`;
|
|
1689
|
+
mei += `${indent}${indent}<fileDesc>\n`;
|
|
1690
|
+
mei += `${indent}${indent}${indent}<titleStmt>\n`;
|
|
1691
|
+
// Add title from metadata if available
|
|
1692
|
+
if (doc.metadata?.title) {
|
|
1693
|
+
mei += `${indent}${indent}${indent}${indent}<title>${escapeXml(doc.metadata.title)}</title>\n`;
|
|
1694
|
+
}
|
|
1695
|
+
// Add subtitle as second title (verovio reads subsequent titles as subtitle)
|
|
1696
|
+
if (doc.metadata?.subtitle) {
|
|
1697
|
+
mei += `${indent}${indent}${indent}${indent}<title>${escapeXml(doc.metadata.subtitle)}</title>\n`;
|
|
1698
|
+
}
|
|
1699
|
+
// Add composer (right-aligned in verovio)
|
|
1700
|
+
if (doc.metadata?.composer) {
|
|
1701
|
+
mei += `${indent}${indent}${indent}${indent}<composer>${escapeXml(doc.metadata.composer)}</composer>\n`;
|
|
1702
|
+
}
|
|
1703
|
+
// Add arranger (right-aligned in verovio)
|
|
1704
|
+
if (doc.metadata?.arranger) {
|
|
1705
|
+
mei += `${indent}${indent}${indent}${indent}<arranger>${escapeXml(doc.metadata.arranger)}</arranger>\n`;
|
|
1706
|
+
}
|
|
1707
|
+
// Add lyricist (left-aligned in verovio)
|
|
1708
|
+
if (doc.metadata?.lyricist) {
|
|
1709
|
+
mei += `${indent}${indent}${indent}${indent}<lyricist>${escapeXml(doc.metadata.lyricist)}</lyricist>\n`;
|
|
1710
|
+
}
|
|
1711
|
+
mei += `${indent}${indent}${indent}</titleStmt>\n`;
|
|
1712
|
+
mei += `${indent}${indent}${indent}<pubStmt />\n`;
|
|
1713
|
+
mei += `${indent}${indent}</fileDesc>\n`;
|
|
1714
|
+
mei += `${indent}${indent}<encodingDesc>\n`;
|
|
1715
|
+
mei += `${indent}${indent}${indent}<projectDesc>\n`;
|
|
1716
|
+
mei += `${indent}${indent}${indent}${indent}<p>Encoded with Lilylet MEIEncoder</p>\n`;
|
|
1717
|
+
mei += `${indent}${indent}${indent}</projectDesc>\n`;
|
|
1718
|
+
mei += `${indent}${indent}</encodingDesc>\n`;
|
|
1719
|
+
mei += `${indent}</meiHead>\n`;
|
|
1720
|
+
mei += `${indent}<music>\n`;
|
|
1721
|
+
mei += `${indent}${indent}<body>\n`;
|
|
1722
|
+
mei += `${indent}${indent}${indent}<mdiv xml:id="${generateId("mdiv")}">\n`;
|
|
1723
|
+
mei += `${indent}${indent}${indent}${indent}<score xml:id="${generateId("score")}">\n`;
|
|
1724
|
+
mei += encodeScoreDef(keySig, currentTimeNum, currentTimeDen, partInfos, `${indent}${indent}${indent}${indent}${indent}`, currentMeterSymbol);
|
|
1725
|
+
mei += `${indent}${indent}${indent}${indent}${indent}<section xml:id="${generateId("section")}">\n`;
|
|
1726
|
+
// Track tie state across measures for cross-measure ties
|
|
1727
|
+
const tieState = {};
|
|
1728
|
+
// Track slur state across measures for cross-measure slurs
|
|
1729
|
+
const slurState = {};
|
|
1730
|
+
// Track hairpin state across measures for cross-measure hairpins
|
|
1731
|
+
const hairpinState = {};
|
|
1732
|
+
// Track ottava state across measures for cross-measure ottava spans
|
|
1733
|
+
const ottavaState = {};
|
|
1734
|
+
// Initialize clef state from partInfos (convert local staff to global staff)
|
|
1735
|
+
const clefState = {};
|
|
1736
|
+
for (let pi = 0; pi < partInfos.length; pi++) {
|
|
1737
|
+
const partInfo = partInfos[pi];
|
|
1738
|
+
for (const [localStaffStr, clef] of Object.entries(partInfo.clefs)) {
|
|
1739
|
+
const globalStaff = partInfo.staffOffset + parseInt(localStaffStr);
|
|
1740
|
+
clefState[globalStaff] = clef;
|
|
1741
|
+
}
|
|
1742
|
+
}
|
|
1743
|
+
// Helper to check if a measure has any musical content
|
|
1744
|
+
const measureHasContent = (measure) => {
|
|
1745
|
+
for (const part of measure.parts) {
|
|
1746
|
+
for (const voice of part.voices) {
|
|
1747
|
+
for (const event of voice.events) {
|
|
1748
|
+
// Check for actual musical content (not just context changes or pitch resets)
|
|
1749
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
1750
|
+
event.type === 'tuplet' || event.type === 'tremolo') {
|
|
1751
|
+
return true;
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
return false;
|
|
1757
|
+
};
|
|
1758
|
+
// Filter out trailing empty measures
|
|
1759
|
+
let measures = doc.measures;
|
|
1760
|
+
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
1761
|
+
measures = measures.slice(0, -1);
|
|
1762
|
+
}
|
|
1763
|
+
// Encode measures
|
|
1764
|
+
measures.forEach((measure, mi) => {
|
|
1765
|
+
// Check for key signature change and output scoreDef if needed
|
|
1766
|
+
if (measure.key) {
|
|
1767
|
+
const newKey = keyToFifths(measure.key);
|
|
1768
|
+
if (newKey !== currentKey) {
|
|
1769
|
+
currentKey = newKey;
|
|
1770
|
+
const newKeySig = KEY_SIGS[currentKey] || "0";
|
|
1771
|
+
// Output a scoreDef with the new key signature
|
|
1772
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}" key.sig="${newKeySig}" />\n`;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
// Check for time signature change and output scoreDef if needed
|
|
1776
|
+
if (measure.timeSig && mi > 0) {
|
|
1777
|
+
const newTimeNum = measure.timeSig.numerator;
|
|
1778
|
+
const newTimeDen = measure.timeSig.denominator;
|
|
1779
|
+
const newMeterSymbol = measure.timeSig.symbol;
|
|
1780
|
+
if (newTimeNum !== currentTimeNum || newTimeDen !== currentTimeDen || newMeterSymbol !== currentMeterSymbol) {
|
|
1781
|
+
currentTimeNum = newTimeNum;
|
|
1782
|
+
currentTimeDen = newTimeDen;
|
|
1783
|
+
currentMeterSymbol = newMeterSymbol;
|
|
1784
|
+
// Output a scoreDef with the new time signature
|
|
1785
|
+
const meterSymAttr = currentMeterSymbol ? ` meter.sym="${currentMeterSymbol}"` : '';
|
|
1786
|
+
mei += `${indent}${indent}${indent}${indent}${indent}${indent}<scoreDef xml:id="${generateId('scoredef')}"${meterSymAttr} meter.count="${currentTimeNum}" meter.unit="${currentTimeDen}" />\n`;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
mei += encodeMeasure(measure, mi + 1, `${indent}${indent}${indent}${indent}${indent}${indent}`, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState);
|
|
1790
|
+
});
|
|
1791
|
+
mei += `${indent}${indent}${indent}${indent}${indent}</section>\n`;
|
|
1792
|
+
mei += `${indent}${indent}${indent}${indent}</score>\n`;
|
|
1793
|
+
mei += `${indent}${indent}${indent}</mdiv>\n`;
|
|
1794
|
+
mei += `${indent}${indent}</body>\n`;
|
|
1795
|
+
mei += `${indent}</music>\n`;
|
|
1796
|
+
mei += '</mei>\n';
|
|
1797
|
+
return mei;
|
|
1798
|
+
};
|
|
1799
|
+
// Escape XML special characters
|
|
1800
|
+
const escapeXml = (text) => {
|
|
1801
|
+
return text
|
|
1802
|
+
.replace(/&/g, '&')
|
|
1803
|
+
.replace(/</g, '<')
|
|
1804
|
+
.replace(/>/g, '>')
|
|
1805
|
+
.replace(/"/g, '"')
|
|
1806
|
+
.replace(/'/g, ''');
|
|
1807
|
+
};
|
|
1808
|
+
export { encode, resetIdCounter, };
|