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