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