@k-l-lambda/lilylet 0.1.72 → 0.1.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/lilylet/highlight.js +97 -97
- package/lib/lilylet/meiEncoder.js +133 -47
- package/lib/lilylet/musicXmlDecoder.d.ts +12 -2
- package/lib/lilylet/musicXmlDecoder.js +327 -62
- package/lib/lilylet/musicXmlTypes.d.ts +2 -1
- package/lib/lilylet/types.d.ts +2 -0
- package/package.json +2 -1
- package/source/lilylet/highlight.ts +97 -97
- package/source/lilylet/meiEncoder.ts +132 -49
- package/source/lilylet/musicXmlDecoder.ts +326 -62
- package/source/lilylet/musicXmlTypes.ts +2 -1
- package/source/lilylet/types.ts +2 -0
package/lib/lilylet/highlight.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// AUTO-GENERATED by tools/buildHighlight.ts from source/lilylet/
|
|
1
|
+
// AUTO-GENERATED by tools/buildHighlight.ts from source/lilylet/grammar.jison.js.
|
|
2
2
|
// Do NOT edit by hand. Run `npm run build:highlight` to regenerate.
|
|
3
3
|
//
|
|
4
4
|
// Framework-agnostic syntax-highlighting definition for Lilylet, derived from
|
|
@@ -10,102 +10,102 @@
|
|
|
10
10
|
* applies LONGEST-match (flex semantics), using order only as a tie-breaker.
|
|
11
11
|
*/
|
|
12
12
|
export const HIGHLIGHT_RULES = [
|
|
13
|
-
{ re:
|
|
14
|
-
{ re:
|
|
15
|
-
{ re:
|
|
16
|
-
{ re:
|
|
17
|
-
{ re:
|
|
18
|
-
{ re:
|
|
19
|
-
{ re:
|
|
20
|
-
{ re:
|
|
21
|
-
{ re:
|
|
22
|
-
{ re:
|
|
23
|
-
{ re:
|
|
24
|
-
{ re:
|
|
25
|
-
{ re:
|
|
26
|
-
{ re: /"[^"]*"/iy, scope: "string" },
|
|
27
|
-
{ re:
|
|
28
|
-
{ re:
|
|
29
|
-
{ re:
|
|
30
|
-
{ re:
|
|
31
|
-
{ re:
|
|
32
|
-
{ re:
|
|
33
|
-
{ re:
|
|
34
|
-
{ re:
|
|
35
|
-
{ re:
|
|
36
|
-
{ re:
|
|
37
|
-
{ re:
|
|
38
|
-
{ re:
|
|
39
|
-
{ re:
|
|
40
|
-
{ re:
|
|
41
|
-
{ re:
|
|
42
|
-
{ re:
|
|
43
|
-
{ re:
|
|
44
|
-
{ re:
|
|
45
|
-
{ re:
|
|
46
|
-
{ re:
|
|
47
|
-
{ re:
|
|
48
|
-
{ re:
|
|
49
|
-
{ re:
|
|
50
|
-
{ re:
|
|
51
|
-
{ re:
|
|
52
|
-
{ re:
|
|
53
|
-
{ re:
|
|
54
|
-
{ re:
|
|
55
|
-
{ re:
|
|
56
|
-
{ re:
|
|
57
|
-
{ re:
|
|
58
|
-
{ re:
|
|
59
|
-
{ re:
|
|
60
|
-
{ re:
|
|
61
|
-
{ re:
|
|
62
|
-
{ re:
|
|
63
|
-
{ re:
|
|
64
|
-
{ re:
|
|
65
|
-
{ re:
|
|
66
|
-
{ re:
|
|
67
|
-
{ re:
|
|
68
|
-
{ re:
|
|
69
|
-
{ re:
|
|
70
|
-
{ re:
|
|
71
|
-
{ re:
|
|
72
|
-
{ re:
|
|
73
|
-
{ re:
|
|
74
|
-
{ re:
|
|
75
|
-
{ re:
|
|
76
|
-
{ re:
|
|
77
|
-
{ re:
|
|
78
|
-
{ re:
|
|
79
|
-
{ re:
|
|
80
|
-
{ re:
|
|
81
|
-
{ re:
|
|
82
|
-
{ re:
|
|
83
|
-
{ re: /tremolo/iy, scope: "keyword" },
|
|
84
|
-
{ re: /[a-g](ss|ff|s|f)
|
|
85
|
-
{ re: /'/iy, scope: "octave" },
|
|
86
|
-
{ re:
|
|
87
|
-
{ re: /[0-9]
|
|
88
|
-
{ re:
|
|
89
|
-
{ re:
|
|
90
|
-
{ re:
|
|
91
|
-
{ re:
|
|
92
|
-
{ re:
|
|
93
|
-
{ re:
|
|
94
|
-
{ re:
|
|
95
|
-
{ re:
|
|
96
|
-
{ re:
|
|
97
|
-
{ re:
|
|
98
|
-
{ re:
|
|
99
|
-
{ re:
|
|
100
|
-
{ re:
|
|
101
|
-
{ re:
|
|
102
|
-
{ re: /[_]/iy, scope: "punctuation" },
|
|
103
|
-
{ re:
|
|
104
|
-
{ re:
|
|
105
|
-
{ re:
|
|
106
|
-
{ re:
|
|
107
|
-
{ re: /[rR]/iy, scope: "rest" },
|
|
108
|
-
{ re: /[sS]/iy, scope: "rest" },
|
|
13
|
+
{ re: /(?:%.*)/iy, scope: "comment" },
|
|
14
|
+
{ re: /(?:\[title\b)/iy, scope: "header" },
|
|
15
|
+
{ re: /(?:\[subtitle\b)/iy, scope: "header" },
|
|
16
|
+
{ re: /(?:\[composer\b)/iy, scope: "header" },
|
|
17
|
+
{ re: /(?:\[arranger\b)/iy, scope: "header" },
|
|
18
|
+
{ re: /(?:\[lyricist\b)/iy, scope: "header" },
|
|
19
|
+
{ re: /(?:\[opus\b)/iy, scope: "header" },
|
|
20
|
+
{ re: /(?:\[instrument-[A-Za-z0-9_]+(?:-[A-Za-z0-9_]+)*)/iy, scope: "header" },
|
|
21
|
+
{ re: /(?:\[instrument\b)/iy, scope: "header" },
|
|
22
|
+
{ re: /(?:\[genre\b)/iy, scope: "header" },
|
|
23
|
+
{ re: /(?:\[staves\b)/iy, scope: "header" },
|
|
24
|
+
{ re: /(?:\[auto-beam\b)/iy, scope: "header" },
|
|
25
|
+
{ re: /(?:\])/iy, scope: "squareBracket" },
|
|
26
|
+
{ re: /(?:"[^"]*")/iy, scope: "string" },
|
|
27
|
+
{ re: /(?:\\clef\b)/iy, scope: "keyword" },
|
|
28
|
+
{ re: /(?:\\key\b)/iy, scope: "keyword" },
|
|
29
|
+
{ re: /(?:\\time\b)/iy, scope: "keyword" },
|
|
30
|
+
{ re: /(?:\\partial\b)/iy, scope: "keyword" },
|
|
31
|
+
{ re: /(?:\\numericTimeSignature\b)/iy, scope: "keyword" },
|
|
32
|
+
{ re: /(?:\\defaultTimeSignature\b)/iy, scope: "keyword" },
|
|
33
|
+
{ re: /(?:\\tempo\b)/iy, scope: "keyword" },
|
|
34
|
+
{ re: /(?:\\staff\b)/iy, scope: "keyword" },
|
|
35
|
+
{ re: /(?:\\grace\b)/iy, scope: "grace" },
|
|
36
|
+
{ re: /(?:\\times\b)/iy, scope: "tuplet" },
|
|
37
|
+
{ re: /(?:\\tuplet\b)/iy, scope: "tuplet" },
|
|
38
|
+
{ re: /(?:\\repeat\b)/iy, scope: "keyword" },
|
|
39
|
+
{ re: /(?:\\ottava\b)/iy, scope: "keyword" },
|
|
40
|
+
{ re: /(?:\\stemUp\b)/iy, scope: "stem" },
|
|
41
|
+
{ re: /(?:\\stemDown\b)/iy, scope: "stem" },
|
|
42
|
+
{ re: /(?:\\stemNeutral\b)/iy, scope: "stem" },
|
|
43
|
+
{ re: /(?:\\major\b)/iy, scope: "mode" },
|
|
44
|
+
{ re: /(?:\\minor\b)/iy, scope: "mode" },
|
|
45
|
+
{ re: /(?:\\sustainOn\b)/iy, scope: "pedal" },
|
|
46
|
+
{ re: /(?:\\sustainOff\b)/iy, scope: "pedal" },
|
|
47
|
+
{ re: /(?:\\bar\b)/iy, scope: "keyword" },
|
|
48
|
+
{ re: /(?:\\coda\b)/iy, scope: "navigation" },
|
|
49
|
+
{ re: /(?:\\segno\b)/iy, scope: "navigation" },
|
|
50
|
+
{ re: /(?:\\chords\b)/iy, scope: "keyword" },
|
|
51
|
+
{ re: /(?:\\markup\b)/iy, scope: "markup" },
|
|
52
|
+
{ re: /(?:\\<)/iy, scope: "hairpin" },
|
|
53
|
+
{ re: /(?:\\>)/iy, scope: "hairpin" },
|
|
54
|
+
{ re: /(?:\\!)/iy, scope: "hairpin" },
|
|
55
|
+
{ re: /(?:\\staccato\b)/iy, scope: "articulation" },
|
|
56
|
+
{ re: /(?:\\staccatissimo\b)/iy, scope: "articulation" },
|
|
57
|
+
{ re: /(?:\\tenuto\b)/iy, scope: "articulation" },
|
|
58
|
+
{ re: /(?:\\marcato\b)/iy, scope: "articulation" },
|
|
59
|
+
{ re: /(?:\\accent\b)/iy, scope: "articulation" },
|
|
60
|
+
{ re: /(?:\\portato\b)/iy, scope: "articulation" },
|
|
61
|
+
{ re: /(?:\\trill\b)/iy, scope: "ornament" },
|
|
62
|
+
{ re: /(?:\\turn\b)/iy, scope: "ornament" },
|
|
63
|
+
{ re: /(?:\\mordent\b)/iy, scope: "ornament" },
|
|
64
|
+
{ re: /(?:\\prall\b)/iy, scope: "ornament" },
|
|
65
|
+
{ re: /(?:\\fermata\b)/iy, scope: "ornament" },
|
|
66
|
+
{ re: /(?:\\shortfermata\b)/iy, scope: "ornament" },
|
|
67
|
+
{ re: /(?:\\arpeggio\b)/iy, scope: "ornament" },
|
|
68
|
+
{ re: /(?:\\ppp\b)/iy, scope: "dynamic" },
|
|
69
|
+
{ re: /(?:\\pp\b)/iy, scope: "dynamic" },
|
|
70
|
+
{ re: /(?:\\mp\b)/iy, scope: "dynamic" },
|
|
71
|
+
{ re: /(?:\\mf\b)/iy, scope: "dynamic" },
|
|
72
|
+
{ re: /(?:\\fff\b)/iy, scope: "dynamic" },
|
|
73
|
+
{ re: /(?:\\ff\b)/iy, scope: "dynamic" },
|
|
74
|
+
{ re: /(?:\\sfz\b)/iy, scope: "dynamic" },
|
|
75
|
+
{ re: /(?:\\rfz\b)/iy, scope: "dynamic" },
|
|
76
|
+
{ re: /(?:\\sf\b)/iy, scope: "dynamic" },
|
|
77
|
+
{ re: /(?:\\fp\b)/iy, scope: "dynamic" },
|
|
78
|
+
{ re: /(?:\\p\b)/iy, scope: "dynamic" },
|
|
79
|
+
{ re: /(?:\\f)/iy, scope: "dynamic" },
|
|
80
|
+
{ re: /(?:\\rest\b)/iy, scope: "rest" },
|
|
81
|
+
{ re: /(?:\\\\\\)/iy, scope: "separator" },
|
|
82
|
+
{ re: /(?:\\\\)/iy, scope: "separator" },
|
|
83
|
+
{ re: /(?:tremolo\b)/iy, scope: "keyword" },
|
|
84
|
+
{ re: /(?:[a-g](ss|ff|s|f)?)/iy, scope: "pitch" },
|
|
85
|
+
{ re: /(?:')/iy, scope: "octave" },
|
|
86
|
+
{ re: /(?:,)/iy, scope: "octave" },
|
|
87
|
+
{ re: /(?:[0-9]+)/iy, scope: "number" },
|
|
88
|
+
{ re: /(?:\/)/iy, scope: "operator" },
|
|
89
|
+
{ re: /(?:#)/iy, scope: "punctuation" },
|
|
90
|
+
{ re: /(?:\{)/iy, scope: "brace" },
|
|
91
|
+
{ re: /(?:\})/iy, scope: "brace" },
|
|
92
|
+
{ re: /(?:<)/iy, scope: "chordBracket" },
|
|
93
|
+
{ re: /(?:>)/iy, scope: "chordBracket" },
|
|
94
|
+
{ re: /(?:\|)/iy, scope: "bar" },
|
|
95
|
+
{ re: /(?:\[)/iy, scope: "squareBracket" },
|
|
96
|
+
{ re: /(?:\])/iy, scope: "squareBracket" },
|
|
97
|
+
{ re: /(?:\()/iy, scope: "paren" },
|
|
98
|
+
{ re: /(?:\))/iy, scope: "paren" },
|
|
99
|
+
{ re: /(?:~)/iy, scope: "tie" },
|
|
100
|
+
{ re: /(?:\.)/iy, scope: "punctuation" },
|
|
101
|
+
{ re: /(?:-)/iy, scope: "punctuation" },
|
|
102
|
+
{ re: /(?:[_])/iy, scope: "punctuation" },
|
|
103
|
+
{ re: /(?:\^)/iy, scope: "punctuation" },
|
|
104
|
+
{ re: /(?:!)/iy, scope: "punctuation" },
|
|
105
|
+
{ re: /(?::)/iy, scope: "operator" },
|
|
106
|
+
{ re: /(?:=)/iy, scope: "operator" },
|
|
107
|
+
{ re: /(?:[rR])/iy, scope: "rest" },
|
|
108
|
+
{ re: /(?:[sS])/iy, scope: "rest" },
|
|
109
109
|
];
|
|
110
110
|
/**
|
|
111
111
|
* Match a single token at `pos` in `line` using longest-match. Returns the
|
|
@@ -333,8 +333,10 @@ const extractMarkOptions = (marks) => {
|
|
|
333
333
|
beamStart: false,
|
|
334
334
|
beamEnd: false,
|
|
335
335
|
dynamic: undefined,
|
|
336
|
+
dynamics: [],
|
|
336
337
|
hairpin: undefined,
|
|
337
338
|
pedal: undefined,
|
|
339
|
+
pedals: [],
|
|
338
340
|
tremolo: undefined,
|
|
339
341
|
fingerings: [],
|
|
340
342
|
navigation: undefined,
|
|
@@ -380,7 +382,8 @@ const extractMarkOptions = (marks) => {
|
|
|
380
382
|
case 'dynamic': {
|
|
381
383
|
const dynStr = DYNAMIC_MAP[mark.type];
|
|
382
384
|
if (dynStr) {
|
|
383
|
-
result.dynamic = dynStr;
|
|
385
|
+
result.dynamic = dynStr; // kept for back-compat (first dynamic)
|
|
386
|
+
result.dynamics.push(dynStr);
|
|
384
387
|
}
|
|
385
388
|
break;
|
|
386
389
|
}
|
|
@@ -391,16 +394,22 @@ const extractMarkOptions = (marks) => {
|
|
|
391
394
|
else if (mark.type === HairpinType.diminuendoStart) {
|
|
392
395
|
result.hairpin = 'dimStart';
|
|
393
396
|
}
|
|
394
|
-
else if (mark.type === HairpinType.crescendoEnd
|
|
395
|
-
result.hairpin = '
|
|
397
|
+
else if (mark.type === HairpinType.crescendoEnd) {
|
|
398
|
+
result.hairpin = 'crescEnd';
|
|
399
|
+
}
|
|
400
|
+
else if (mark.type === HairpinType.diminuendoEnd) {
|
|
401
|
+
result.hairpin = 'dimEnd';
|
|
396
402
|
}
|
|
397
403
|
break;
|
|
398
404
|
case 'pedal':
|
|
405
|
+
// A note can carry more than one pedal mark (a pedal "bounce": an up
|
|
406
|
+
// to release the previous pedal and an immediate down to re-pedal on
|
|
407
|
+
// the same note), so collect ALL of them rather than keep one scalar.
|
|
399
408
|
if (mark.type === PedalType.sustainOn) {
|
|
400
|
-
result.
|
|
409
|
+
result.pedals.push('down');
|
|
401
410
|
}
|
|
402
411
|
else if (mark.type === PedalType.sustainOff) {
|
|
403
|
-
result.
|
|
412
|
+
result.pedals.push('up');
|
|
404
413
|
}
|
|
405
414
|
break;
|
|
406
415
|
case 'tie':
|
|
@@ -489,6 +498,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
489
498
|
elementId: noteId,
|
|
490
499
|
hairpin: markOptions.hairpin,
|
|
491
500
|
pedal: markOptions.pedal,
|
|
501
|
+
pedals: markOptions.pedals,
|
|
492
502
|
hasTieStart: markOptions.tieStart,
|
|
493
503
|
pitches: event.pitches,
|
|
494
504
|
arpeggio: markOptions.arpeggio,
|
|
@@ -497,6 +507,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
497
507
|
mordent: markOptions.mordent,
|
|
498
508
|
turn: markOptions.turn,
|
|
499
509
|
dynamic: markOptions.dynamic,
|
|
510
|
+
dynamics: markOptions.dynamics,
|
|
500
511
|
slurStart: markOptions.slurStart,
|
|
501
512
|
slurEnd: markOptions.slurEnd,
|
|
502
513
|
fingerings: markOptions.fingerings,
|
|
@@ -549,6 +560,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
549
560
|
elementId: chordId,
|
|
550
561
|
hairpin: markOptions.hairpin,
|
|
551
562
|
pedal: markOptions.pedal,
|
|
563
|
+
pedals: markOptions.pedals,
|
|
552
564
|
hasTieStart: markOptions.tieStart,
|
|
553
565
|
pitches: event.pitches,
|
|
554
566
|
arpeggio: markOptions.arpeggio,
|
|
@@ -557,6 +569,7 @@ const noteEventToMEI = (event, indent, layerStaff, tieEnd, contextStemDir, keyFi
|
|
|
557
569
|
mordent: markOptions.mordent,
|
|
558
570
|
turn: markOptions.turn,
|
|
559
571
|
dynamic: markOptions.dynamic,
|
|
572
|
+
dynamics: markOptions.dynamics,
|
|
560
573
|
slurStart: markOptions.slurStart,
|
|
561
574
|
slurEnd: markOptions.slurEnd,
|
|
562
575
|
fingerings: markOptions.fingerings,
|
|
@@ -574,6 +587,15 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
|
|
|
574
587
|
// Cross-staff attribute
|
|
575
588
|
if (crossStaff)
|
|
576
589
|
attrs += ` staff="${crossStaff}"`;
|
|
590
|
+
// A rest may carry a fermata (held silence). Surface it so the layer loop can
|
|
591
|
+
// emit a <fermata> control event referencing this rest's id.
|
|
592
|
+
let fermata;
|
|
593
|
+
if (event.marks) {
|
|
594
|
+
for (const mk of event.marks) {
|
|
595
|
+
if (mk.markType === 'ornament' && mk.type === 'fermata')
|
|
596
|
+
fermata = 'normal';
|
|
597
|
+
}
|
|
598
|
+
}
|
|
577
599
|
// Pitched rest (positioned at specific pitch)
|
|
578
600
|
if (event.pitch) {
|
|
579
601
|
const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
|
|
@@ -581,14 +603,14 @@ const restEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAc
|
|
|
581
603
|
}
|
|
582
604
|
// Space rest (invisible)
|
|
583
605
|
if (event.invisible) {
|
|
584
|
-
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
|
|
606
|
+
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId, fermata };
|
|
585
607
|
}
|
|
586
608
|
// Full measure rest
|
|
587
609
|
if (event.fullMeasure) {
|
|
588
610
|
const mRestId = generateId('mrest');
|
|
589
|
-
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
|
|
611
|
+
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId, fermata };
|
|
590
612
|
}
|
|
591
|
-
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
|
|
613
|
+
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId, fermata };
|
|
592
614
|
};
|
|
593
615
|
// Check if a tuplet has balanced internal beam groups (both [ and ] inside the same tuplet)
|
|
594
616
|
// Returns false for cross-tuplet beams where [ is in one tuplet and ] in another
|
|
@@ -627,6 +649,9 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
627
649
|
const mordents = [];
|
|
628
650
|
const turns = [];
|
|
629
651
|
const arpeggios = [];
|
|
652
|
+
const pedals = [];
|
|
653
|
+
const fingerings = [];
|
|
654
|
+
const markups = [];
|
|
630
655
|
// Handle internal beam groups: if notes have manual beam marks, respect them
|
|
631
656
|
const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
|
|
632
657
|
let beamOpen = false;
|
|
@@ -656,8 +681,9 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
656
681
|
if (result.slurEnd)
|
|
657
682
|
slurEnds.push(result.elementId);
|
|
658
683
|
// Collect other control events
|
|
659
|
-
if (result.
|
|
660
|
-
|
|
684
|
+
if (result.dynamics)
|
|
685
|
+
for (const label of result.dynamics)
|
|
686
|
+
dynamics.push({ startid: result.elementId, label });
|
|
661
687
|
if (result.fermata)
|
|
662
688
|
fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
|
|
663
689
|
if (result.trill)
|
|
@@ -668,6 +694,16 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
668
694
|
turns.push({ startid: result.elementId });
|
|
669
695
|
if (result.arpeggio)
|
|
670
696
|
arpeggios.push({ plist: result.elementId });
|
|
697
|
+
if (result.pedals)
|
|
698
|
+
for (const dir of result.pedals)
|
|
699
|
+
pedals.push({ startId: result.elementId, dir });
|
|
700
|
+
// Fingerings and text directions (markup) on inner notes are control
|
|
701
|
+
// events that attach by id — collect them so the layer loop can emit
|
|
702
|
+
// <fing>/<dir> for them (previously silently dropped inside tuplets).
|
|
703
|
+
for (const fing of result.fingerings)
|
|
704
|
+
fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
|
|
705
|
+
for (const mkup of result.markups)
|
|
706
|
+
markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
|
|
671
707
|
// Close beam if this note ends a beam group
|
|
672
708
|
if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
|
|
673
709
|
xml += `${baseIndent}</beam>\n`;
|
|
@@ -676,7 +712,10 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
676
712
|
}
|
|
677
713
|
else if (e.type === 'rest') {
|
|
678
714
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
679
|
-
|
|
715
|
+
const restResult = restEventToMEI(e, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
716
|
+
xml += restResult.xml;
|
|
717
|
+
if (restResult.fermata)
|
|
718
|
+
fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
680
719
|
}
|
|
681
720
|
else if (e.type === 'context') {
|
|
682
721
|
const ctx = e;
|
|
@@ -697,7 +736,7 @@ const tupletEventToMEI = (event, indent, layerStaff, keyFifths = 0, currentStaff
|
|
|
697
736
|
xml += `${baseIndent}</beam>\n`;
|
|
698
737
|
}
|
|
699
738
|
xml += `${indent}</tuplet>\n`;
|
|
700
|
-
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
|
|
739
|
+
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
|
|
701
740
|
};
|
|
702
741
|
// Convert TremoloEvent to MEI (fingered tremolo - alternating between two notes)
|
|
703
742
|
const tremoloEventToMEI = (event, indent, keyFifths = 0, ottavaShift = 0, measureAccidentals) => {
|
|
@@ -803,16 +842,20 @@ const getEventBeamMarks = (event) => {
|
|
|
803
842
|
return { beamStart: false, beamEnd: false };
|
|
804
843
|
};
|
|
805
844
|
// Encode a layer (voice)
|
|
806
|
-
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef,
|
|
845
|
+
const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths = 0, initialClef, initialSlurs = [], initialHairpin = null, initialOctave = null) => {
|
|
807
846
|
const layerId = generateId("layer");
|
|
808
847
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
809
848
|
let beamElementOpen = false; // Whether actual <beam> element is open (passed to tuplets)
|
|
810
849
|
const baseIndent = indent + ' ';
|
|
811
850
|
// Track current clef to only emit changes
|
|
812
851
|
let currentClef = initialClef;
|
|
813
|
-
// Track hairpin spans
|
|
852
|
+
// Track hairpin spans. Use a stack of open hairpins (not a single slot): the
|
|
853
|
+
// flattened layer stream can interleave overlapping/cross-staff hairpins
|
|
854
|
+
// (e.g. a crescendo starting before the previous one ended), and a single slot
|
|
855
|
+
// would silently overwrite the earlier one. Seed with any hairpin still open
|
|
856
|
+
// from the previous measure (cross-measure carry).
|
|
814
857
|
const hairpins = [];
|
|
815
|
-
|
|
858
|
+
const openHairpins = initialHairpin ? [initialHairpin] : [];
|
|
816
859
|
// Track pedal marks (each is independent, not paired spans)
|
|
817
860
|
const pedals = [];
|
|
818
861
|
// Track octave spans - initialize from previous measure if continuing
|
|
@@ -823,9 +866,15 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
823
866
|
let currentOttavaShift = initialOctave?.shift || 0; // Track current ottava shift for pitch encoding
|
|
824
867
|
let lastNoteId = null; // Track last note id for ending ottava spans
|
|
825
868
|
let ottavaExplicitlyClosed = false; // Track if ottava was explicitly closed by \ottava #0
|
|
826
|
-
// Track slur spans - slurs must be encoded as control events in MEI
|
|
869
|
+
// Track slur spans - slurs must be encoded as control events in MEI.
|
|
870
|
+
// A single slot can't hold the overlapping/concurrent slurs that piano writing
|
|
871
|
+
// produces (measured up to 3 open at once per voice); a new start while one was
|
|
872
|
+
// open overwrote it and the span was lost. Use a STACK and pair each end to the
|
|
873
|
+
// most-recent open slur (LIFO), matching the hairpin fix. `currentSlur` is kept
|
|
874
|
+
// as a view of the stack top for the existing cross-measure carry plumbing.
|
|
827
875
|
const slurs = [];
|
|
828
|
-
|
|
876
|
+
const openSlurs = initialSlurs.map(startId => ({ startId }));
|
|
877
|
+
let currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
829
878
|
// Track arpeggio refs
|
|
830
879
|
const arpeggios = [];
|
|
831
880
|
// Track ornament refs
|
|
@@ -962,38 +1011,52 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
962
1011
|
}
|
|
963
1012
|
// Track hairpin spans
|
|
964
1013
|
if (result.hairpin === 'crescStart') {
|
|
965
|
-
|
|
1014
|
+
openHairpins.push({ form: 'cres', startId: result.elementId });
|
|
966
1015
|
}
|
|
967
1016
|
else if (result.hairpin === 'dimStart') {
|
|
968
|
-
|
|
969
|
-
}
|
|
970
|
-
else if (result.hairpin === '
|
|
1017
|
+
openHairpins.push({ form: 'dim', startId: result.elementId });
|
|
1018
|
+
}
|
|
1019
|
+
else if ((result.hairpin === 'crescEnd' || result.hairpin === 'dimEnd') && openHairpins.length > 0) {
|
|
1020
|
+
const endForm = result.hairpin === 'crescEnd' ? 'cres' : 'dim';
|
|
1021
|
+
// Close the most-recent open hairpin of the matching form; if none
|
|
1022
|
+
// matches (interleaved/malformed input), fall back to the newest open.
|
|
1023
|
+
let idx = -1;
|
|
1024
|
+
for (let i = openHairpins.length - 1; i >= 0; i--) {
|
|
1025
|
+
if (openHairpins[i].form === endForm) {
|
|
1026
|
+
idx = i;
|
|
1027
|
+
break;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
if (idx < 0)
|
|
1031
|
+
idx = openHairpins.length - 1;
|
|
1032
|
+
const open = openHairpins.splice(idx, 1)[0];
|
|
971
1033
|
hairpins.push({
|
|
972
|
-
form:
|
|
973
|
-
startId:
|
|
1034
|
+
form: open.form,
|
|
1035
|
+
startId: open.startId,
|
|
974
1036
|
endId: result.elementId,
|
|
975
1037
|
});
|
|
976
|
-
currentHairpin = null;
|
|
977
1038
|
}
|
|
978
|
-
// Track pedal marks (each is independent
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
}
|
|
1039
|
+
// Track pedal marks (each is independent; a note may carry several,
|
|
1040
|
+
// e.g. an up+down pedal bounce at the same beat).
|
|
1041
|
+
if (result.pedals) {
|
|
1042
|
+
for (const dir of result.pedals) {
|
|
1043
|
+
pedals.push({ startId: result.elementId, dir });
|
|
1044
|
+
}
|
|
984
1045
|
}
|
|
985
1046
|
// Track slur spans - end must be processed before start
|
|
986
|
-
// in case a note ends one slur and starts another
|
|
987
|
-
|
|
1047
|
+
// in case a note ends one slur and starts another.
|
|
1048
|
+
// Pair an end to the most-recent open slur (LIFO).
|
|
1049
|
+
if (result.slurEnd && openSlurs.length > 0) {
|
|
1050
|
+
const open = openSlurs.pop();
|
|
988
1051
|
slurs.push({
|
|
989
|
-
startId:
|
|
1052
|
+
startId: open.startId,
|
|
990
1053
|
endId: result.elementId,
|
|
991
1054
|
});
|
|
992
|
-
currentSlur = null;
|
|
993
1055
|
}
|
|
994
1056
|
if (result.slurStart) {
|
|
995
|
-
|
|
1057
|
+
openSlurs.push({ startId: result.elementId });
|
|
996
1058
|
}
|
|
1059
|
+
currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
997
1060
|
// Track arpeggio refs
|
|
998
1061
|
if (result.arpeggio) {
|
|
999
1062
|
arpeggios.push({ plist: result.elementId });
|
|
@@ -1017,8 +1080,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1017
1080
|
if (result.turn) {
|
|
1018
1081
|
turns.push({ startid: result.elementId });
|
|
1019
1082
|
}
|
|
1020
|
-
if (result.
|
|
1021
|
-
|
|
1083
|
+
if (result.dynamics) {
|
|
1084
|
+
for (const label of result.dynamics)
|
|
1085
|
+
dynamics.push({ startid: result.elementId, label });
|
|
1022
1086
|
}
|
|
1023
1087
|
// Track fingerings
|
|
1024
1088
|
for (const fing of result.fingerings) {
|
|
@@ -1039,6 +1103,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1039
1103
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
1040
1104
|
const restResult = restEventToMEI(event, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1041
1105
|
xml += restResult.xml;
|
|
1106
|
+
// Fermata over a rest (held silence) — emit as a control event on the rest.
|
|
1107
|
+
if (restResult.fermata)
|
|
1108
|
+
fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
1042
1109
|
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1043
1110
|
flushPendingMarkups(restResult.elementId);
|
|
1044
1111
|
flushPendingDynamics(restResult.elementId);
|
|
@@ -1061,20 +1128,21 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1061
1128
|
flushPendingDynamics(tupletResult.firstNoteId);
|
|
1062
1129
|
lastNoteId = tupletResult.firstNoteId;
|
|
1063
1130
|
}
|
|
1064
|
-
// Process slur ends first (to close
|
|
1131
|
+
// Process slur ends first (to close open slurs, LIFO), then starts.
|
|
1065
1132
|
for (const endId of tupletResult.slurEnds) {
|
|
1066
|
-
if (
|
|
1133
|
+
if (openSlurs.length > 0) {
|
|
1134
|
+
const open = openSlurs.pop();
|
|
1067
1135
|
slurs.push({
|
|
1068
|
-
startId:
|
|
1136
|
+
startId: open.startId,
|
|
1069
1137
|
endId: endId,
|
|
1070
1138
|
});
|
|
1071
|
-
currentSlur = null;
|
|
1072
1139
|
}
|
|
1073
1140
|
}
|
|
1074
1141
|
// Then process slur starts (to open new slurs)
|
|
1075
1142
|
for (const startId of tupletResult.slurStarts) {
|
|
1076
|
-
|
|
1143
|
+
openSlurs.push({ startId });
|
|
1077
1144
|
}
|
|
1145
|
+
currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
1078
1146
|
// Collect other control events from tuplet
|
|
1079
1147
|
dynamics.push(...tupletResult.dynamics);
|
|
1080
1148
|
fermatas.push(...tupletResult.fermatas);
|
|
@@ -1082,6 +1150,9 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1082
1150
|
mordents.push(...tupletResult.mordents);
|
|
1083
1151
|
turns.push(...tupletResult.turns);
|
|
1084
1152
|
arpeggios.push(...tupletResult.arpeggios);
|
|
1153
|
+
pedals.push(...tupletResult.pedals);
|
|
1154
|
+
fingerings.push(...tupletResult.fingerings);
|
|
1155
|
+
markups.push(...tupletResult.markups);
|
|
1085
1156
|
break;
|
|
1086
1157
|
}
|
|
1087
1158
|
case 'tremolo':
|
|
@@ -1222,8 +1293,23 @@ const encodeLayer = (voice, layerN, indent, initialTiePitches = [], keyFifths =
|
|
|
1222
1293
|
const pendingOctave = currentOctave
|
|
1223
1294
|
? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true, emitted: currentOctave.emitted, endToken: currentOctave.endToken, endFallbackId: currentOctave.endFallbackId }
|
|
1224
1295
|
: null;
|
|
1296
|
+
// Resolve hairpins still open at layer end. The cross-measure carry supports a
|
|
1297
|
+
// single pending hairpin, so keep the OLDEST open span (bottom of stack — most
|
|
1298
|
+
// likely to legitimately continue into the next measure) as pendingHairpin, and
|
|
1299
|
+
// flush the rest here, ending them at the last note so they aren't dropped
|
|
1300
|
+
// (MusicXML files routinely leave wedges unclosed). Without a last note id there
|
|
1301
|
+
// is nothing to attach to, so those are unavoidably dropped.
|
|
1302
|
+
let pendingHairpin = null;
|
|
1303
|
+
if (openHairpins.length > 0) {
|
|
1304
|
+
pendingHairpin = openHairpins[0];
|
|
1305
|
+
for (let i = 1; i < openHairpins.length; i++) {
|
|
1306
|
+
if (lastNoteId) {
|
|
1307
|
+
hairpins.push({ form: openHairpins[i].form, startId: openHairpins[i].startId, endId: lastNoteId });
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1225
1311
|
xml += `${indent}</layer>\n`;
|
|
1226
|
-
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur:
|
|
1312
|
+
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
|
|
1227
1313
|
};
|
|
1228
1314
|
// Encode a staff
|
|
1229
1315
|
const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hairpinState = {}, ottavaState = {}, keyFifths = 0, initialClef) => {
|
|
@@ -1260,7 +1346,7 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1260
1346
|
const layerN = vi + 1;
|
|
1261
1347
|
const tieKey = `${staffN}-${layerN}`;
|
|
1262
1348
|
const initialTies = tieState[tieKey] || [];
|
|
1263
|
-
const initialSlur = slurState[tieKey] ||
|
|
1349
|
+
const initialSlur = slurState[tieKey] || [];
|
|
1264
1350
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
1265
1351
|
const initialOctave = ottavaState[tieKey] || null;
|
|
1266
1352
|
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
|
|
@@ -1286,9 +1372,9 @@ const encodeStaff = (voices, staffN, indent, tieState = {}, slurState = {}, hair
|
|
|
1286
1372
|
pendingTies[tieKey] = result.pendingTiePitches;
|
|
1287
1373
|
}
|
|
1288
1374
|
// Track pending slurs for this layer
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1375
|
+
// Always record (even empty) so a measure that closed all its slurs
|
|
1376
|
+
// clears the carried stack rather than leaving a stale open slur.
|
|
1377
|
+
pendingSlurs[tieKey] = result.pendingSlur;
|
|
1292
1378
|
// Track pending hairpins for this layer
|
|
1293
1379
|
if (result.pendingHairpin) {
|
|
1294
1380
|
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
@@ -8,13 +8,23 @@ import { LilyletDoc } from './types';
|
|
|
8
8
|
/**
|
|
9
9
|
* Decode MusicXML string to LilyletDoc
|
|
10
10
|
*/
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Decode raw MusicXML bytes (or a string) into a clean UTF-8/UTF-16-correct
|
|
13
|
+
* JS string. MuseScore/Finale/Sibelius frequently export `.xml` as UTF-16 LE
|
|
14
|
+
* with a BOM; reading those as UTF-8 yields mojibake and a failed parse.
|
|
15
|
+
*
|
|
16
|
+
* Detection order: byte-order mark → declared `encoding="..."` in the XML
|
|
17
|
+
* prolog → default UTF-8. A leading BOM is always stripped (xmldom chokes on a
|
|
18
|
+
* U+FEFF before `<?xml`).
|
|
19
|
+
*/
|
|
20
|
+
export declare const readXmlString: (input: string | Uint8Array) => string;
|
|
21
|
+
export declare const decode: (input: string | Uint8Array) => LilyletDoc;
|
|
12
22
|
/**
|
|
13
23
|
* Decode MusicXML file to LilyletDoc
|
|
14
24
|
*/
|
|
15
25
|
export declare const decodeFile: (filePath: string) => Promise<LilyletDoc>;
|
|
16
26
|
declare const _default: {
|
|
17
|
-
decode: (
|
|
27
|
+
decode: (input: string | Uint8Array) => LilyletDoc;
|
|
18
28
|
decodeFile: (filePath: string) => Promise<LilyletDoc>;
|
|
19
29
|
};
|
|
20
30
|
export default _default;
|