@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
|
@@ -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
|
|
@@ -56,102 +56,102 @@ export interface HighlightToken {
|
|
|
56
56
|
* applies LONGEST-match (flex semantics), using order only as a tie-breaker.
|
|
57
57
|
*/
|
|
58
58
|
export const HIGHLIGHT_RULES: HighlightRule[] = [
|
|
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: /"[^"]*"/iy, scope: "string" },
|
|
73
|
-
{ re:
|
|
74
|
-
{ re:
|
|
75
|
-
{ re:
|
|
76
|
-
{ re:
|
|
77
|
-
{ re:
|
|
78
|
-
{ re:
|
|
79
|
-
{ re:
|
|
80
|
-
{ re:
|
|
81
|
-
{ re:
|
|
82
|
-
{ re:
|
|
83
|
-
{ re:
|
|
84
|
-
{ re:
|
|
85
|
-
{ re:
|
|
86
|
-
{ re:
|
|
87
|
-
{ re:
|
|
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:
|
|
103
|
-
{ re:
|
|
104
|
-
{ re:
|
|
105
|
-
{ re:
|
|
106
|
-
{ re:
|
|
107
|
-
{ re:
|
|
108
|
-
{ re:
|
|
109
|
-
{ re:
|
|
110
|
-
{ re:
|
|
111
|
-
{ re:
|
|
112
|
-
{ re:
|
|
113
|
-
{ re:
|
|
114
|
-
{ re:
|
|
115
|
-
{ re:
|
|
116
|
-
{ re:
|
|
117
|
-
{ re:
|
|
118
|
-
{ re:
|
|
119
|
-
{ re:
|
|
120
|
-
{ re:
|
|
121
|
-
{ re:
|
|
122
|
-
{ re:
|
|
123
|
-
{ re:
|
|
124
|
-
{ re:
|
|
125
|
-
{ re:
|
|
126
|
-
{ re:
|
|
127
|
-
{ re:
|
|
128
|
-
{ re:
|
|
129
|
-
{ re: /tremolo/iy, scope: "keyword" },
|
|
130
|
-
{ re: /[a-g](ss|ff|s|f)
|
|
131
|
-
{ re: /'/iy, scope: "octave" },
|
|
132
|
-
{ re:
|
|
133
|
-
{ re: /[0-9]
|
|
134
|
-
{ re:
|
|
135
|
-
{ re:
|
|
136
|
-
{ re:
|
|
137
|
-
{ re:
|
|
138
|
-
{ re:
|
|
139
|
-
{ re:
|
|
140
|
-
{ re:
|
|
141
|
-
{ re:
|
|
142
|
-
{ re:
|
|
143
|
-
{ re:
|
|
144
|
-
{ re:
|
|
145
|
-
{ re:
|
|
146
|
-
{ re:
|
|
147
|
-
{ re:
|
|
148
|
-
{ re: /[_]/iy, scope: "punctuation" },
|
|
149
|
-
{ re:
|
|
150
|
-
{ re:
|
|
151
|
-
{ re:
|
|
152
|
-
{ re:
|
|
153
|
-
{ re: /[rR]/iy, scope: "rest" },
|
|
154
|
-
{ re: /[sS]/iy, scope: "rest" },
|
|
59
|
+
{ re: /(?:%.*)/iy, scope: "comment" },
|
|
60
|
+
{ re: /(?:\[title\b)/iy, scope: "header" },
|
|
61
|
+
{ re: /(?:\[subtitle\b)/iy, scope: "header" },
|
|
62
|
+
{ re: /(?:\[composer\b)/iy, scope: "header" },
|
|
63
|
+
{ re: /(?:\[arranger\b)/iy, scope: "header" },
|
|
64
|
+
{ re: /(?:\[lyricist\b)/iy, scope: "header" },
|
|
65
|
+
{ re: /(?:\[opus\b)/iy, scope: "header" },
|
|
66
|
+
{ re: /(?:\[instrument-[A-Za-z0-9_]+(?:-[A-Za-z0-9_]+)*)/iy, scope: "header" },
|
|
67
|
+
{ re: /(?:\[instrument\b)/iy, scope: "header" },
|
|
68
|
+
{ re: /(?:\[genre\b)/iy, scope: "header" },
|
|
69
|
+
{ re: /(?:\[staves\b)/iy, scope: "header" },
|
|
70
|
+
{ re: /(?:\[auto-beam\b)/iy, scope: "header" },
|
|
71
|
+
{ re: /(?:\])/iy, scope: "squareBracket" },
|
|
72
|
+
{ re: /(?:"[^"]*")/iy, scope: "string" },
|
|
73
|
+
{ re: /(?:\\clef\b)/iy, scope: "keyword" },
|
|
74
|
+
{ re: /(?:\\key\b)/iy, scope: "keyword" },
|
|
75
|
+
{ re: /(?:\\time\b)/iy, scope: "keyword" },
|
|
76
|
+
{ re: /(?:\\partial\b)/iy, scope: "keyword" },
|
|
77
|
+
{ re: /(?:\\numericTimeSignature\b)/iy, scope: "keyword" },
|
|
78
|
+
{ re: /(?:\\defaultTimeSignature\b)/iy, scope: "keyword" },
|
|
79
|
+
{ re: /(?:\\tempo\b)/iy, scope: "keyword" },
|
|
80
|
+
{ re: /(?:\\staff\b)/iy, scope: "keyword" },
|
|
81
|
+
{ re: /(?:\\grace\b)/iy, scope: "grace" },
|
|
82
|
+
{ re: /(?:\\times\b)/iy, scope: "tuplet" },
|
|
83
|
+
{ re: /(?:\\tuplet\b)/iy, scope: "tuplet" },
|
|
84
|
+
{ re: /(?:\\repeat\b)/iy, scope: "keyword" },
|
|
85
|
+
{ re: /(?:\\ottava\b)/iy, scope: "keyword" },
|
|
86
|
+
{ re: /(?:\\stemUp\b)/iy, scope: "stem" },
|
|
87
|
+
{ re: /(?:\\stemDown\b)/iy, scope: "stem" },
|
|
88
|
+
{ re: /(?:\\stemNeutral\b)/iy, scope: "stem" },
|
|
89
|
+
{ re: /(?:\\major\b)/iy, scope: "mode" },
|
|
90
|
+
{ re: /(?:\\minor\b)/iy, scope: "mode" },
|
|
91
|
+
{ re: /(?:\\sustainOn\b)/iy, scope: "pedal" },
|
|
92
|
+
{ re: /(?:\\sustainOff\b)/iy, scope: "pedal" },
|
|
93
|
+
{ re: /(?:\\bar\b)/iy, scope: "keyword" },
|
|
94
|
+
{ re: /(?:\\coda\b)/iy, scope: "navigation" },
|
|
95
|
+
{ re: /(?:\\segno\b)/iy, scope: "navigation" },
|
|
96
|
+
{ re: /(?:\\chords\b)/iy, scope: "keyword" },
|
|
97
|
+
{ re: /(?:\\markup\b)/iy, scope: "markup" },
|
|
98
|
+
{ re: /(?:\\<)/iy, scope: "hairpin" },
|
|
99
|
+
{ re: /(?:\\>)/iy, scope: "hairpin" },
|
|
100
|
+
{ re: /(?:\\!)/iy, scope: "hairpin" },
|
|
101
|
+
{ re: /(?:\\staccato\b)/iy, scope: "articulation" },
|
|
102
|
+
{ re: /(?:\\staccatissimo\b)/iy, scope: "articulation" },
|
|
103
|
+
{ re: /(?:\\tenuto\b)/iy, scope: "articulation" },
|
|
104
|
+
{ re: /(?:\\marcato\b)/iy, scope: "articulation" },
|
|
105
|
+
{ re: /(?:\\accent\b)/iy, scope: "articulation" },
|
|
106
|
+
{ re: /(?:\\portato\b)/iy, scope: "articulation" },
|
|
107
|
+
{ re: /(?:\\trill\b)/iy, scope: "ornament" },
|
|
108
|
+
{ re: /(?:\\turn\b)/iy, scope: "ornament" },
|
|
109
|
+
{ re: /(?:\\mordent\b)/iy, scope: "ornament" },
|
|
110
|
+
{ re: /(?:\\prall\b)/iy, scope: "ornament" },
|
|
111
|
+
{ re: /(?:\\fermata\b)/iy, scope: "ornament" },
|
|
112
|
+
{ re: /(?:\\shortfermata\b)/iy, scope: "ornament" },
|
|
113
|
+
{ re: /(?:\\arpeggio\b)/iy, scope: "ornament" },
|
|
114
|
+
{ re: /(?:\\ppp\b)/iy, scope: "dynamic" },
|
|
115
|
+
{ re: /(?:\\pp\b)/iy, scope: "dynamic" },
|
|
116
|
+
{ re: /(?:\\mp\b)/iy, scope: "dynamic" },
|
|
117
|
+
{ re: /(?:\\mf\b)/iy, scope: "dynamic" },
|
|
118
|
+
{ re: /(?:\\fff\b)/iy, scope: "dynamic" },
|
|
119
|
+
{ re: /(?:\\ff\b)/iy, scope: "dynamic" },
|
|
120
|
+
{ re: /(?:\\sfz\b)/iy, scope: "dynamic" },
|
|
121
|
+
{ re: /(?:\\rfz\b)/iy, scope: "dynamic" },
|
|
122
|
+
{ re: /(?:\\sf\b)/iy, scope: "dynamic" },
|
|
123
|
+
{ re: /(?:\\fp\b)/iy, scope: "dynamic" },
|
|
124
|
+
{ re: /(?:\\p\b)/iy, scope: "dynamic" },
|
|
125
|
+
{ re: /(?:\\f)/iy, scope: "dynamic" },
|
|
126
|
+
{ re: /(?:\\rest\b)/iy, scope: "rest" },
|
|
127
|
+
{ re: /(?:\\\\\\)/iy, scope: "separator" },
|
|
128
|
+
{ re: /(?:\\\\)/iy, scope: "separator" },
|
|
129
|
+
{ re: /(?:tremolo\b)/iy, scope: "keyword" },
|
|
130
|
+
{ re: /(?:[a-g](ss|ff|s|f)?)/iy, scope: "pitch" },
|
|
131
|
+
{ re: /(?:')/iy, scope: "octave" },
|
|
132
|
+
{ re: /(?:,)/iy, scope: "octave" },
|
|
133
|
+
{ re: /(?:[0-9]+)/iy, scope: "number" },
|
|
134
|
+
{ re: /(?:\/)/iy, scope: "operator" },
|
|
135
|
+
{ re: /(?:#)/iy, scope: "punctuation" },
|
|
136
|
+
{ re: /(?:\{)/iy, scope: "brace" },
|
|
137
|
+
{ re: /(?:\})/iy, scope: "brace" },
|
|
138
|
+
{ re: /(?:<)/iy, scope: "chordBracket" },
|
|
139
|
+
{ re: /(?:>)/iy, scope: "chordBracket" },
|
|
140
|
+
{ re: /(?:\|)/iy, scope: "bar" },
|
|
141
|
+
{ re: /(?:\[)/iy, scope: "squareBracket" },
|
|
142
|
+
{ re: /(?:\])/iy, scope: "squareBracket" },
|
|
143
|
+
{ re: /(?:\()/iy, scope: "paren" },
|
|
144
|
+
{ re: /(?:\))/iy, scope: "paren" },
|
|
145
|
+
{ re: /(?:~)/iy, scope: "tie" },
|
|
146
|
+
{ re: /(?:\.)/iy, scope: "punctuation" },
|
|
147
|
+
{ re: /(?:-)/iy, scope: "punctuation" },
|
|
148
|
+
{ re: /(?:[_])/iy, scope: "punctuation" },
|
|
149
|
+
{ re: /(?:\^)/iy, scope: "punctuation" },
|
|
150
|
+
{ re: /(?:!)/iy, scope: "punctuation" },
|
|
151
|
+
{ re: /(?::)/iy, scope: "operator" },
|
|
152
|
+
{ re: /(?:=)/iy, scope: "operator" },
|
|
153
|
+
{ re: /(?:[rR])/iy, scope: "rest" },
|
|
154
|
+
{ re: /(?:[sS])/iy, scope: "rest" },
|
|
155
155
|
];
|
|
156
156
|
|
|
157
157
|
/**
|
|
@@ -410,8 +410,10 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
410
410
|
beamStart: boolean;
|
|
411
411
|
beamEnd: boolean;
|
|
412
412
|
dynamic?: string;
|
|
413
|
+
dynamics: string[]; // all dynamics on this event (a note may carry several, e.g. fp written as two marks)
|
|
413
414
|
hairpin?: string;
|
|
414
415
|
pedal?: string;
|
|
416
|
+
pedals?: ('up' | 'down')[];
|
|
415
417
|
tremolo?: number;
|
|
416
418
|
fingerings: { finger: number; placement?: 'above' | 'below' }[];
|
|
417
419
|
navigation?: 'coda' | 'segno';
|
|
@@ -430,8 +432,10 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
430
432
|
beamStart: false,
|
|
431
433
|
beamEnd: false,
|
|
432
434
|
dynamic: undefined as string | undefined,
|
|
435
|
+
dynamics: [] as string[],
|
|
433
436
|
hairpin: undefined as string | undefined,
|
|
434
437
|
pedal: undefined as string | undefined,
|
|
438
|
+
pedals: [] as ('up' | 'down')[],
|
|
435
439
|
tremolo: undefined as number | undefined,
|
|
436
440
|
fingerings: [] as { finger: number; placement?: 'above' | 'below' }[],
|
|
437
441
|
navigation: undefined as 'coda' | 'segno' | undefined,
|
|
@@ -472,7 +476,8 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
472
476
|
case 'dynamic': {
|
|
473
477
|
const dynStr = DYNAMIC_MAP[mark.type];
|
|
474
478
|
if (dynStr) {
|
|
475
|
-
result.dynamic = dynStr;
|
|
479
|
+
result.dynamic = dynStr; // kept for back-compat (first dynamic)
|
|
480
|
+
result.dynamics.push(dynStr);
|
|
476
481
|
}
|
|
477
482
|
break;
|
|
478
483
|
}
|
|
@@ -481,15 +486,20 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
481
486
|
result.hairpin = 'crescStart';
|
|
482
487
|
} else if (mark.type === HairpinType.diminuendoStart) {
|
|
483
488
|
result.hairpin = 'dimStart';
|
|
484
|
-
} else if (mark.type === HairpinType.crescendoEnd
|
|
485
|
-
result.hairpin = '
|
|
489
|
+
} else if (mark.type === HairpinType.crescendoEnd) {
|
|
490
|
+
result.hairpin = 'crescEnd';
|
|
491
|
+
} else if (mark.type === HairpinType.diminuendoEnd) {
|
|
492
|
+
result.hairpin = 'dimEnd';
|
|
486
493
|
}
|
|
487
494
|
break;
|
|
488
495
|
case 'pedal':
|
|
496
|
+
// A note can carry more than one pedal mark (a pedal "bounce": an up
|
|
497
|
+
// to release the previous pedal and an immediate down to re-pedal on
|
|
498
|
+
// the same note), so collect ALL of them rather than keep one scalar.
|
|
489
499
|
if (mark.type === PedalType.sustainOn) {
|
|
490
|
-
result.
|
|
500
|
+
result.pedals.push('down');
|
|
491
501
|
} else if (mark.type === PedalType.sustainOff) {
|
|
492
|
-
result.
|
|
502
|
+
result.pedals.push('up');
|
|
493
503
|
}
|
|
494
504
|
break;
|
|
495
505
|
case 'tie':
|
|
@@ -544,6 +554,7 @@ interface NoteEventResult {
|
|
|
544
554
|
elementId: string;
|
|
545
555
|
hairpin?: string;
|
|
546
556
|
pedal?: string;
|
|
557
|
+
pedals?: ('up' | 'down')[];
|
|
547
558
|
hasTieStart: boolean;
|
|
548
559
|
pitches: Pitch[];
|
|
549
560
|
arpeggio: boolean;
|
|
@@ -551,7 +562,8 @@ interface NoteEventResult {
|
|
|
551
562
|
trill: boolean;
|
|
552
563
|
mordent: 'lower' | 'upper' | false; // lower = mordent, upper = prall
|
|
553
564
|
turn: boolean;
|
|
554
|
-
dynamic?: string; // dynamic marking (p, pp, f, ff, etc.)
|
|
565
|
+
dynamic?: string; // dynamic marking (p, pp, f, ff, etc.) — first one (back-compat)
|
|
566
|
+
dynamics: string[]; // all dynamics on this event
|
|
555
567
|
slurStart: boolean; // For tracking slur spans
|
|
556
568
|
slurEnd: boolean; // For tracking slur spans
|
|
557
569
|
fingerings: { finger: number; placement?: 'above' | 'below' }[];
|
|
@@ -610,6 +622,7 @@ const noteEventToMEI = (
|
|
|
610
622
|
elementId: noteId,
|
|
611
623
|
hairpin: markOptions.hairpin,
|
|
612
624
|
pedal: markOptions.pedal,
|
|
625
|
+
pedals: markOptions.pedals,
|
|
613
626
|
hasTieStart: markOptions.tieStart,
|
|
614
627
|
pitches: event.pitches,
|
|
615
628
|
arpeggio: markOptions.arpeggio,
|
|
@@ -618,6 +631,7 @@ const noteEventToMEI = (
|
|
|
618
631
|
mordent: markOptions.mordent,
|
|
619
632
|
turn: markOptions.turn,
|
|
620
633
|
dynamic: markOptions.dynamic,
|
|
634
|
+
dynamics: markOptions.dynamics,
|
|
621
635
|
slurStart: markOptions.slurStart,
|
|
622
636
|
slurEnd: markOptions.slurEnd,
|
|
623
637
|
fingerings: markOptions.fingerings,
|
|
@@ -671,6 +685,7 @@ const noteEventToMEI = (
|
|
|
671
685
|
elementId: chordId,
|
|
672
686
|
hairpin: markOptions.hairpin,
|
|
673
687
|
pedal: markOptions.pedal,
|
|
688
|
+
pedals: markOptions.pedals,
|
|
674
689
|
hasTieStart: markOptions.tieStart,
|
|
675
690
|
pitches: event.pitches,
|
|
676
691
|
arpeggio: markOptions.arpeggio,
|
|
@@ -679,6 +694,7 @@ const noteEventToMEI = (
|
|
|
679
694
|
mordent: markOptions.mordent,
|
|
680
695
|
turn: markOptions.turn,
|
|
681
696
|
dynamic: markOptions.dynamic,
|
|
697
|
+
dynamics: markOptions.dynamics,
|
|
682
698
|
slurStart: markOptions.slurStart,
|
|
683
699
|
slurEnd: markOptions.slurEnd,
|
|
684
700
|
fingerings: markOptions.fingerings,
|
|
@@ -689,7 +705,7 @@ const noteEventToMEI = (
|
|
|
689
705
|
|
|
690
706
|
|
|
691
707
|
// Convert RestEvent to MEI
|
|
692
|
-
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): { xml: string; elementId: string } => {
|
|
708
|
+
const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0, ottavaShift: number = 0, measureAccidentals?: Map<string, string>, crossStaff?: number): { xml: string; elementId: string; fermata?: 'normal' | 'short' } => {
|
|
693
709
|
const dur = DURATIONS[event.duration.division] || "4";
|
|
694
710
|
const restId = generateId('rest');
|
|
695
711
|
let attrs = `xml:id="${restId}" dur="${dur}"`;
|
|
@@ -698,6 +714,15 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
698
714
|
// Cross-staff attribute
|
|
699
715
|
if (crossStaff) attrs += ` staff="${crossStaff}"`;
|
|
700
716
|
|
|
717
|
+
// A rest may carry a fermata (held silence). Surface it so the layer loop can
|
|
718
|
+
// emit a <fermata> control event referencing this rest's id.
|
|
719
|
+
let fermata: 'normal' | 'short' | undefined;
|
|
720
|
+
if (event.marks) {
|
|
721
|
+
for (const mk of event.marks) {
|
|
722
|
+
if (mk.markType === 'ornament' && (mk as { type?: string }).type === 'fermata') fermata = 'normal';
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
701
726
|
// Pitched rest (positioned at specific pitch)
|
|
702
727
|
if (event.pitch) {
|
|
703
728
|
const pitch = encodePitch(event.pitch, keyFifths, ottavaShift);
|
|
@@ -706,16 +731,16 @@ const restEventToMEI = (event: RestEvent, indent: string, keyFifths: number = 0,
|
|
|
706
731
|
|
|
707
732
|
// Space rest (invisible)
|
|
708
733
|
if (event.invisible) {
|
|
709
|
-
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId };
|
|
734
|
+
return { xml: `${indent}<space ${attrs} />\n`, elementId: restId, fermata };
|
|
710
735
|
}
|
|
711
736
|
|
|
712
737
|
// Full measure rest
|
|
713
738
|
if (event.fullMeasure) {
|
|
714
739
|
const mRestId = generateId('mrest');
|
|
715
|
-
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId };
|
|
740
|
+
return { xml: `${indent}<mRest xml:id="${mRestId}" />\n`, elementId: mRestId, fermata };
|
|
716
741
|
}
|
|
717
742
|
|
|
718
|
-
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId };
|
|
743
|
+
return { xml: `${indent}<rest ${attrs} />\n`, elementId: restId, fermata };
|
|
719
744
|
};
|
|
720
745
|
|
|
721
746
|
|
|
@@ -731,6 +756,9 @@ interface TupletEventResult {
|
|
|
731
756
|
mordents: MordentRef[];
|
|
732
757
|
turns: TurnRef[];
|
|
733
758
|
arpeggios: ArpegRef[];
|
|
759
|
+
pedals: PedalMark[]; // pedal marks on notes inside the tuplet (independent events)
|
|
760
|
+
fingerings: FingerRef[]; // fingering marks on notes inside the tuplet
|
|
761
|
+
markups: MarkupRef[]; // text directions (markup) on notes inside the tuplet
|
|
734
762
|
endingClef?: string; // Updated clef name if changed inside the tuplet
|
|
735
763
|
}
|
|
736
764
|
|
|
@@ -774,6 +802,9 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
774
802
|
const mordents: MordentRef[] = [];
|
|
775
803
|
const turns: TurnRef[] = [];
|
|
776
804
|
const arpeggios: ArpegRef[] = [];
|
|
805
|
+
const pedals: PedalMark[] = [];
|
|
806
|
+
const fingerings: FingerRef[] = [];
|
|
807
|
+
const markups: MarkupRef[] = [];
|
|
777
808
|
|
|
778
809
|
// Handle internal beam groups: if notes have manual beam marks, respect them
|
|
779
810
|
const hasInternalBeams = !inParentBeam && tupletHasInternalBeams(event);
|
|
@@ -808,12 +839,20 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
808
839
|
if (result.slurEnd) slurEnds.push(result.elementId);
|
|
809
840
|
|
|
810
841
|
// Collect other control events
|
|
811
|
-
if (result.
|
|
842
|
+
if (result.dynamics) for (const label of result.dynamics) dynamics.push({ startid: result.elementId, label });
|
|
812
843
|
if (result.fermata) fermatas.push({ startid: result.elementId, shape: result.fermata === 'short' ? 'angular' : undefined });
|
|
813
844
|
if (result.trill) trills.push({ startid: result.elementId });
|
|
814
845
|
if (result.mordent) mordents.push({ startid: result.elementId, form: result.mordent === 'upper' ? 'upper' : undefined });
|
|
815
846
|
if (result.turn) turns.push({ startid: result.elementId });
|
|
816
847
|
if (result.arpeggio) arpeggios.push({ plist: result.elementId });
|
|
848
|
+
if (result.pedals) for (const dir of result.pedals) pedals.push({ startId: result.elementId, dir });
|
|
849
|
+
// Fingerings and text directions (markup) on inner notes are control
|
|
850
|
+
// events that attach by id — collect them so the layer loop can emit
|
|
851
|
+
// <fing>/<dir> for them (previously silently dropped inside tuplets).
|
|
852
|
+
for (const fing of result.fingerings)
|
|
853
|
+
fingerings.push({ startid: result.elementId, finger: fing.finger, placement: fing.placement });
|
|
854
|
+
for (const mkup of result.markups)
|
|
855
|
+
markups.push({ startid: result.elementId, content: mkup.content, placement: mkup.placement });
|
|
817
856
|
|
|
818
857
|
// Close beam if this note ends a beam group
|
|
819
858
|
if (hasInternalBeams && markOptions.beamEnd && beamOpen) {
|
|
@@ -822,7 +861,9 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
822
861
|
}
|
|
823
862
|
} else if (e.type === 'rest') {
|
|
824
863
|
const restIndent = beamOpen ? baseIndent + ' ' : baseIndent;
|
|
825
|
-
|
|
864
|
+
const restResult = restEventToMEI(e as RestEvent, restIndent, keyFifths, ottavaShift, measureAccidentals);
|
|
865
|
+
xml += restResult.xml;
|
|
866
|
+
if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
826
867
|
} else if (e.type === 'context') {
|
|
827
868
|
const ctx = e as ContextChange;
|
|
828
869
|
if (ctx.clef && ctx.clef !== activeClef) {
|
|
@@ -844,7 +885,7 @@ const tupletEventToMEI = (event: TupletEvent, indent: string, layerStaff?: numbe
|
|
|
844
885
|
}
|
|
845
886
|
|
|
846
887
|
xml += `${indent}</tuplet>\n`;
|
|
847
|
-
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, endingClef };
|
|
888
|
+
return { xml, firstNoteId, slurStarts, slurEnds, dynamics, fermatas, trills, mordents, turns, arpeggios, pedals, fingerings, markups, endingClef };
|
|
848
889
|
};
|
|
849
890
|
|
|
850
891
|
|
|
@@ -997,7 +1038,7 @@ interface SlurSpan {
|
|
|
997
1038
|
|
|
998
1039
|
// Tie state for cross-measure ties - maps staff:layer to pending pitches
|
|
999
1040
|
type TieState = Record<string, Pitch[]>;
|
|
1000
|
-
type SlurState = Record<string, string
|
|
1041
|
+
type SlurState = Record<string, string[]>; // voice key -> open slur startIds (stack, oldest first)
|
|
1001
1042
|
type HairpinState = Record<string, { form: 'cres' | 'dim'; startId: string } | null>; // voice key -> pending hairpin
|
|
1002
1043
|
|
|
1003
1044
|
// Pending octave span for cross-measure continuation
|
|
@@ -1032,7 +1073,7 @@ interface LayerResult {
|
|
|
1032
1073
|
barlines: BarlineRef[];
|
|
1033
1074
|
markups: MarkupRef[];
|
|
1034
1075
|
pendingTiePitches: Pitch[]; // For cross-measure tie tracking
|
|
1035
|
-
pendingSlur: string
|
|
1076
|
+
pendingSlur: string[]; // For cross-measure slur tracking (all open slur startIds)
|
|
1036
1077
|
pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null; // For cross-measure hairpin tracking
|
|
1037
1078
|
pendingOctave: PendingOctave | null; // For cross-measure ottava span tracking
|
|
1038
1079
|
ottavaExplicitlyClosed: boolean; // True if ottava was closed by explicit \ottava #0 in this layer
|
|
@@ -1071,7 +1112,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEve
|
|
|
1071
1112
|
};
|
|
1072
1113
|
|
|
1073
1114
|
// Encode a layer (voice)
|
|
1074
|
-
const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef,
|
|
1115
|
+
const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePitches: Pitch[] = [], keyFifths: number = 0, initialClef?: Clef, initialSlurs: string[] = [], initialHairpin: { form: 'cres' | 'dim'; startId: string } | null = null, initialOctave: PendingOctave | null = null): LayerResult => {
|
|
1075
1116
|
const layerId = generateId("layer");
|
|
1076
1117
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
1077
1118
|
|
|
@@ -1081,9 +1122,13 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1081
1122
|
// Track current clef to only emit changes
|
|
1082
1123
|
let currentClef: Clef | undefined = initialClef;
|
|
1083
1124
|
|
|
1084
|
-
// Track hairpin spans
|
|
1125
|
+
// Track hairpin spans. Use a stack of open hairpins (not a single slot): the
|
|
1126
|
+
// flattened layer stream can interleave overlapping/cross-staff hairpins
|
|
1127
|
+
// (e.g. a crescendo starting before the previous one ended), and a single slot
|
|
1128
|
+
// would silently overwrite the earlier one. Seed with any hairpin still open
|
|
1129
|
+
// from the previous measure (cross-measure carry).
|
|
1085
1130
|
const hairpins: HairpinSpan[] = [];
|
|
1086
|
-
|
|
1131
|
+
const openHairpins: { form: 'cres' | 'dim'; startId: string }[] = initialHairpin ? [initialHairpin] : [];
|
|
1087
1132
|
|
|
1088
1133
|
// Track pedal marks (each is independent, not paired spans)
|
|
1089
1134
|
const pedals: PedalMark[] = [];
|
|
@@ -1098,9 +1143,15 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1098
1143
|
let lastNoteId: string | null = null; // Track last note id for ending ottava spans
|
|
1099
1144
|
let ottavaExplicitlyClosed: boolean = false; // Track if ottava was explicitly closed by \ottava #0
|
|
1100
1145
|
|
|
1101
|
-
// Track slur spans - slurs must be encoded as control events in MEI
|
|
1146
|
+
// Track slur spans - slurs must be encoded as control events in MEI.
|
|
1147
|
+
// A single slot can't hold the overlapping/concurrent slurs that piano writing
|
|
1148
|
+
// produces (measured up to 3 open at once per voice); a new start while one was
|
|
1149
|
+
// open overwrote it and the span was lost. Use a STACK and pair each end to the
|
|
1150
|
+
// most-recent open slur (LIFO), matching the hairpin fix. `currentSlur` is kept
|
|
1151
|
+
// as a view of the stack top for the existing cross-measure carry plumbing.
|
|
1102
1152
|
const slurs: SlurSpan[] = [];
|
|
1103
|
-
|
|
1153
|
+
const openSlurs: { startId: string }[] = initialSlurs.map(startId => ({ startId }));
|
|
1154
|
+
let currentSlur: { startId: string } | null = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
1104
1155
|
|
|
1105
1156
|
// Track arpeggio refs
|
|
1106
1157
|
const arpeggios: ArpegRef[] = [];
|
|
@@ -1256,38 +1307,48 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1256
1307
|
|
|
1257
1308
|
// Track hairpin spans
|
|
1258
1309
|
if (result.hairpin === 'crescStart') {
|
|
1259
|
-
|
|
1310
|
+
openHairpins.push({ form: 'cres', startId: result.elementId });
|
|
1260
1311
|
} else if (result.hairpin === 'dimStart') {
|
|
1261
|
-
|
|
1262
|
-
} else if (result.hairpin === '
|
|
1312
|
+
openHairpins.push({ form: 'dim', startId: result.elementId });
|
|
1313
|
+
} else if ((result.hairpin === 'crescEnd' || result.hairpin === 'dimEnd') && openHairpins.length > 0) {
|
|
1314
|
+
const endForm: 'cres' | 'dim' = result.hairpin === 'crescEnd' ? 'cres' : 'dim';
|
|
1315
|
+
// Close the most-recent open hairpin of the matching form; if none
|
|
1316
|
+
// matches (interleaved/malformed input), fall back to the newest open.
|
|
1317
|
+
let idx = -1;
|
|
1318
|
+
for (let i = openHairpins.length - 1; i >= 0; i--) {
|
|
1319
|
+
if (openHairpins[i].form === endForm) { idx = i; break; }
|
|
1320
|
+
}
|
|
1321
|
+
if (idx < 0) idx = openHairpins.length - 1;
|
|
1322
|
+
const open = openHairpins.splice(idx, 1)[0];
|
|
1263
1323
|
hairpins.push({
|
|
1264
|
-
form:
|
|
1265
|
-
startId:
|
|
1324
|
+
form: open.form,
|
|
1325
|
+
startId: open.startId,
|
|
1266
1326
|
endId: result.elementId,
|
|
1267
1327
|
});
|
|
1268
|
-
currentHairpin = null;
|
|
1269
1328
|
}
|
|
1270
1329
|
|
|
1271
|
-
// Track pedal marks (each is independent
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
}
|
|
1330
|
+
// Track pedal marks (each is independent; a note may carry several,
|
|
1331
|
+
// e.g. an up+down pedal bounce at the same beat).
|
|
1332
|
+
if (result.pedals) {
|
|
1333
|
+
for (const dir of result.pedals) {
|
|
1334
|
+
pedals.push({ startId: result.elementId, dir });
|
|
1335
|
+
}
|
|
1277
1336
|
}
|
|
1278
1337
|
|
|
1279
1338
|
// Track slur spans - end must be processed before start
|
|
1280
|
-
// in case a note ends one slur and starts another
|
|
1281
|
-
|
|
1339
|
+
// in case a note ends one slur and starts another.
|
|
1340
|
+
// Pair an end to the most-recent open slur (LIFO).
|
|
1341
|
+
if (result.slurEnd && openSlurs.length > 0) {
|
|
1342
|
+
const open = openSlurs.pop()!;
|
|
1282
1343
|
slurs.push({
|
|
1283
|
-
startId:
|
|
1344
|
+
startId: open.startId,
|
|
1284
1345
|
endId: result.elementId,
|
|
1285
1346
|
});
|
|
1286
|
-
currentSlur = null;
|
|
1287
1347
|
}
|
|
1288
1348
|
if (result.slurStart) {
|
|
1289
|
-
|
|
1349
|
+
openSlurs.push({ startId: result.elementId });
|
|
1290
1350
|
}
|
|
1351
|
+
currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
1291
1352
|
|
|
1292
1353
|
// Track arpeggio refs
|
|
1293
1354
|
if (result.arpeggio) {
|
|
@@ -1313,8 +1374,8 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1313
1374
|
if (result.turn) {
|
|
1314
1375
|
turns.push({ startid: result.elementId });
|
|
1315
1376
|
}
|
|
1316
|
-
if (result.
|
|
1317
|
-
dynamics.push({ startid: result.elementId, label
|
|
1377
|
+
if (result.dynamics) {
|
|
1378
|
+
for (const label of result.dynamics) dynamics.push({ startid: result.elementId, label });
|
|
1318
1379
|
}
|
|
1319
1380
|
// Track fingerings
|
|
1320
1381
|
for (const fing of result.fingerings) {
|
|
@@ -1335,6 +1396,8 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1335
1396
|
const restCrossStaff = currentStaff !== (voice.staff || 1) ? currentStaff : undefined;
|
|
1336
1397
|
const restResult = restEventToMEI(event as RestEvent, currentIndent, keyFifths, currentOttavaShift, measureAccidentals, restCrossStaff);
|
|
1337
1398
|
xml += restResult.xml;
|
|
1399
|
+
// Fermata over a rest (held silence) — emit as a control event on the rest.
|
|
1400
|
+
if (restResult.fermata) fermatas.push({ startid: restResult.elementId, shape: restResult.fermata === 'short' ? 'angular' : undefined });
|
|
1338
1401
|
// A leading dynamic/markup attaches to the next event, which may be this rest
|
|
1339
1402
|
flushPendingMarkups(restResult.elementId);
|
|
1340
1403
|
flushPendingDynamics(restResult.elementId);
|
|
@@ -1360,21 +1423,22 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1360
1423
|
lastNoteId = tupletResult.firstNoteId;
|
|
1361
1424
|
}
|
|
1362
1425
|
|
|
1363
|
-
// Process slur ends first (to close
|
|
1426
|
+
// Process slur ends first (to close open slurs, LIFO), then starts.
|
|
1364
1427
|
for (const endId of tupletResult.slurEnds) {
|
|
1365
|
-
if (
|
|
1428
|
+
if (openSlurs.length > 0) {
|
|
1429
|
+
const open = openSlurs.pop()!;
|
|
1366
1430
|
slurs.push({
|
|
1367
|
-
startId:
|
|
1431
|
+
startId: open.startId,
|
|
1368
1432
|
endId: endId,
|
|
1369
1433
|
});
|
|
1370
|
-
currentSlur = null;
|
|
1371
1434
|
}
|
|
1372
1435
|
}
|
|
1373
1436
|
|
|
1374
1437
|
// Then process slur starts (to open new slurs)
|
|
1375
1438
|
for (const startId of tupletResult.slurStarts) {
|
|
1376
|
-
|
|
1439
|
+
openSlurs.push({ startId });
|
|
1377
1440
|
}
|
|
1441
|
+
currentSlur = openSlurs.length ? openSlurs[openSlurs.length - 1] : null;
|
|
1378
1442
|
|
|
1379
1443
|
// Collect other control events from tuplet
|
|
1380
1444
|
dynamics.push(...tupletResult.dynamics);
|
|
@@ -1383,6 +1447,9 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1383
1447
|
mordents.push(...tupletResult.mordents);
|
|
1384
1448
|
turns.push(...tupletResult.turns);
|
|
1385
1449
|
arpeggios.push(...tupletResult.arpeggios);
|
|
1450
|
+
pedals.push(...tupletResult.pedals);
|
|
1451
|
+
fingerings.push(...tupletResult.fingerings);
|
|
1452
|
+
markups.push(...tupletResult.markups);
|
|
1386
1453
|
|
|
1387
1454
|
break;
|
|
1388
1455
|
}
|
|
@@ -1524,8 +1591,24 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1524
1591
|
? { dis: currentOctave.dis, disPlace: currentOctave.disPlace, startId: currentOctave.startId, shift: currentOttavaShift, continued: true, emitted: currentOctave.emitted, endToken: currentOctave.endToken, endFallbackId: currentOctave.endFallbackId }
|
|
1525
1592
|
: null;
|
|
1526
1593
|
|
|
1594
|
+
// Resolve hairpins still open at layer end. The cross-measure carry supports a
|
|
1595
|
+
// single pending hairpin, so keep the OLDEST open span (bottom of stack — most
|
|
1596
|
+
// likely to legitimately continue into the next measure) as pendingHairpin, and
|
|
1597
|
+
// flush the rest here, ending them at the last note so they aren't dropped
|
|
1598
|
+
// (MusicXML files routinely leave wedges unclosed). Without a last note id there
|
|
1599
|
+
// is nothing to attach to, so those are unavoidably dropped.
|
|
1600
|
+
let pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null = null;
|
|
1601
|
+
if (openHairpins.length > 0) {
|
|
1602
|
+
pendingHairpin = openHairpins[0];
|
|
1603
|
+
for (let i = 1; i < openHairpins.length; i++) {
|
|
1604
|
+
if (lastNoteId) {
|
|
1605
|
+
hairpins.push({ form: openHairpins[i].form, startId: openHairpins[i].startId, endId: lastNoteId });
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1527
1610
|
xml += `${indent}</layer>\n`;
|
|
1528
|
-
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur:
|
|
1611
|
+
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 };
|
|
1529
1612
|
};
|
|
1530
1613
|
|
|
1531
1614
|
// Staff result type
|
|
@@ -1591,7 +1674,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1591
1674
|
const layerN = vi + 1;
|
|
1592
1675
|
const tieKey = `${staffN}-${layerN}`;
|
|
1593
1676
|
const initialTies = tieState[tieKey] || [];
|
|
1594
|
-
const initialSlur = slurState[tieKey] ||
|
|
1677
|
+
const initialSlur = slurState[tieKey] || [];
|
|
1595
1678
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
1596
1679
|
const initialOctave = ottavaState[tieKey] || null;
|
|
1597
1680
|
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave);
|
|
@@ -1617,9 +1700,9 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1617
1700
|
pendingTies[tieKey] = result.pendingTiePitches;
|
|
1618
1701
|
}
|
|
1619
1702
|
// Track pending slurs for this layer
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1703
|
+
// Always record (even empty) so a measure that closed all its slurs
|
|
1704
|
+
// clears the carried stack rather than leaving a stale open slur.
|
|
1705
|
+
pendingSlurs[tieKey] = result.pendingSlur;
|
|
1623
1706
|
// Track pending hairpins for this layer
|
|
1624
1707
|
if (result.pendingHairpin) {
|
|
1625
1708
|
pendingHairpins[tieKey] = result.pendingHairpin;
|