@k-l-lambda/lilylet 0.1.49 → 0.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/abc/abc.d.ts +102 -0
- package/lib/abc/abc.js +25 -0
- package/lib/abc/grammar.jison.js +1203 -0
- package/lib/abc/parser.d.ts +3 -0
- package/lib/abc/parser.js +6 -0
- package/lib/abcDecoder.d.ts +1 -0
- package/lib/abcDecoder.js +1 -0
- package/lib/grammar.jison.js +1 -1303
- package/lib/index.d.ts +1 -8
- package/lib/index.js +1 -10
- package/lib/lilylet/abcDecoder.d.ts +25 -0
- package/lib/lilylet/abcDecoder.js +1007 -0
- package/lib/lilylet/grammar.jison.js +1308 -0
- package/lib/lilylet/index.d.ts +10 -0
- package/lib/lilylet/index.js +10 -0
- package/lib/lilylet/lilypondDecoder.d.ts +29 -0
- package/lib/lilylet/lilypondDecoder.js +1053 -0
- package/lib/lilylet/lilypondEncoder.d.ts +34 -0
- package/lib/lilylet/lilypondEncoder.js +759 -0
- package/lib/lilylet/meiEncoder.d.ts +8 -0
- package/lib/lilylet/meiEncoder.js +1808 -0
- package/lib/lilylet/musicXmlDecoder.d.ts +20 -0
- package/lib/lilylet/musicXmlDecoder.js +1195 -0
- package/lib/lilylet/musicXmlEncoder.d.ts +15 -0
- package/lib/lilylet/musicXmlEncoder.js +701 -0
- package/lib/lilylet/musicXmlTypes.d.ts +199 -0
- package/lib/lilylet/musicXmlTypes.js +7 -0
- package/lib/lilylet/musicXmlUtils.d.ts +92 -0
- package/lib/lilylet/musicXmlUtils.js +469 -0
- package/lib/lilylet/parser.d.ts +3 -0
- package/lib/lilylet/parser.js +151 -0
- package/lib/lilylet/serializer.d.ts +11 -0
- package/lib/lilylet/serializer.js +653 -0
- package/lib/lilylet/types.d.ts +245 -0
- package/lib/lilylet/types.js +99 -0
- package/lib/lilypondDecoder.d.ts +1 -29
- package/lib/lilypondDecoder.js +1 -1006
- package/lib/lilypondEncoder.d.ts +1 -34
- package/lib/lilypondEncoder.js +1 -759
- package/lib/meiEncoder.d.ts +1 -8
- package/lib/meiEncoder.js +1 -1545
- package/lib/musicXmlDecoder.d.ts +1 -20
- package/lib/musicXmlDecoder.js +1 -1151
- package/lib/musicXmlEncoder.d.ts +1 -15
- package/lib/musicXmlEncoder.js +1 -666
- package/lib/musicXmlTypes.d.ts +1 -199
- package/lib/musicXmlTypes.js +1 -7
- package/lib/musicXmlUtils.d.ts +1 -81
- package/lib/musicXmlUtils.js +1 -435
- package/lib/parser.d.ts +1 -3
- package/lib/parser.js +1 -151
- package/lib/serializer.d.ts +1 -11
- package/lib/serializer.js +1 -650
- package/lib/types.d.ts +1 -244
- package/lib/types.js +1 -99
- package/package.json +2 -1
- package/source/abc/abc.jison +692 -0
- package/source/abc/abc.ts +176 -0
- package/source/abc/grammar.jison.js +1203 -0
- package/source/abc/parser.ts +12 -0
- package/source/lilylet/abcDecoder.ts +1121 -0
- package/source/lilylet/grammar.jison.js +170 -165
- package/source/lilylet/index.ts +4 -3
- package/source/lilylet/lilylet.jison +2 -0
- package/source/lilylet/lilypondDecoder.ts +91 -41
- package/source/lilylet/meiEncoder.ts +280 -0
- package/source/lilylet/musicXmlDecoder.ts +74 -27
- package/source/lilylet/musicXmlEncoder.ts +201 -146
- package/source/lilylet/musicXmlUtils.ts +46 -4
- package/source/lilylet/serializer.ts +3 -0
- package/source/lilylet/types.ts +1 -0
|
@@ -37,11 +37,14 @@ import {
|
|
|
37
37
|
Fraction,
|
|
38
38
|
} from "./types";
|
|
39
39
|
|
|
40
|
+
import {
|
|
41
|
+
DIVISIONS,
|
|
42
|
+
DIVISION_TO_TYPE,
|
|
43
|
+
calculateDuration,
|
|
44
|
+
} from "./musicXmlUtils";
|
|
40
45
|
|
|
41
|
-
// === Constants and Reverse Mappings ===
|
|
42
46
|
|
|
43
|
-
//
|
|
44
|
-
const DIVISIONS = 4;
|
|
47
|
+
// === Constants and Reverse Mappings ===
|
|
45
48
|
|
|
46
49
|
// Phonet to MusicXML step
|
|
47
50
|
const PHONET_TO_STEP: Record<string, string> = {
|
|
@@ -63,19 +66,6 @@ const ACCIDENTAL_TO_ALTER: Record<string, number> = {
|
|
|
63
66
|
natural: 0,
|
|
64
67
|
};
|
|
65
68
|
|
|
66
|
-
// Division to MusicXML note type
|
|
67
|
-
const DIVISION_TO_TYPE: Record<number, string> = {
|
|
68
|
-
0.5: 'breve',
|
|
69
|
-
1: 'whole',
|
|
70
|
-
2: 'half',
|
|
71
|
-
4: 'quarter',
|
|
72
|
-
8: 'eighth',
|
|
73
|
-
16: '16th',
|
|
74
|
-
32: '32nd',
|
|
75
|
-
64: '64th',
|
|
76
|
-
128: '128th',
|
|
77
|
-
};
|
|
78
|
-
|
|
79
69
|
// Key signature to fifths (major keys)
|
|
80
70
|
const KEY_TO_FIFTHS: Record<string, number> = {
|
|
81
71
|
'c': 0,
|
|
@@ -161,34 +151,6 @@ const indent = (level: number): string => ' '.repeat(level);
|
|
|
161
151
|
|
|
162
152
|
// === Encoding Functions ===
|
|
163
153
|
|
|
164
|
-
/**
|
|
165
|
-
* Calculate duration in MusicXML divisions
|
|
166
|
-
*/
|
|
167
|
-
const calculateDuration = (duration: Duration): number => {
|
|
168
|
-
// Base duration: DIVISIONS * (4 / division)
|
|
169
|
-
// e.g., quarter (4) = DIVISIONS * 1 = 4
|
|
170
|
-
// half (2) = DIVISIONS * 2 = 8
|
|
171
|
-
// eighth (8) = DIVISIONS * 0.5 = 2
|
|
172
|
-
let dur = DIVISIONS * (4 / duration.division);
|
|
173
|
-
|
|
174
|
-
// Apply dots
|
|
175
|
-
if (duration.dots) {
|
|
176
|
-
let dotValue = dur / 2;
|
|
177
|
-
for (let i = 0; i < duration.dots; i++) {
|
|
178
|
-
dur += dotValue;
|
|
179
|
-
dotValue /= 2;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Apply tuplet ratio
|
|
184
|
-
if (duration.tuplet) {
|
|
185
|
-
dur = dur * duration.tuplet.denominator / duration.tuplet.numerator;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return Math.round(dur);
|
|
189
|
-
};
|
|
190
|
-
|
|
191
|
-
|
|
192
154
|
/**
|
|
193
155
|
* Encode pitch to MusicXML
|
|
194
156
|
*/
|
|
@@ -224,9 +186,12 @@ const encodeDuration = (duration: Duration, level: number): string => {
|
|
|
224
186
|
}
|
|
225
187
|
|
|
226
188
|
if (duration.tuplet) {
|
|
189
|
+
// MusicXML: actual-notes = notes played (Lilylet denominator)
|
|
190
|
+
// normal-notes = normal count (Lilylet numerator)
|
|
191
|
+
// e.g., \times 2/3 → actual=3, normal=2
|
|
227
192
|
xml += `${indent(level)}<time-modification>\n`;
|
|
228
|
-
xml += `${indent(level + 1)}<actual-notes>${duration.tuplet.
|
|
229
|
-
xml += `${indent(level + 1)}<normal-notes>${duration.tuplet.
|
|
193
|
+
xml += `${indent(level + 1)}<actual-notes>${duration.tuplet.denominator}</actual-notes>\n`;
|
|
194
|
+
xml += `${indent(level + 1)}<normal-notes>${duration.tuplet.numerator}</normal-notes>\n`;
|
|
230
195
|
xml += `${indent(level)}</time-modification>\n`;
|
|
231
196
|
}
|
|
232
197
|
|
|
@@ -349,6 +314,10 @@ const encodeNotations = (marks: Mark[], level: number): string => {
|
|
|
349
314
|
otherNotations.push(`<slur type="${mark.start ? 'start' : 'stop'}" number="1"/>`);
|
|
350
315
|
break;
|
|
351
316
|
|
|
317
|
+
case 'tuplet' as any:
|
|
318
|
+
otherNotations.push(`<tuplet type="${(mark as any).start ? 'start' : 'stop'}"/>`);
|
|
319
|
+
break;
|
|
320
|
+
|
|
352
321
|
case 'fingering':
|
|
353
322
|
// Fingering goes in technical
|
|
354
323
|
break;
|
|
@@ -494,6 +463,49 @@ const encodeRest = (
|
|
|
494
463
|
};
|
|
495
464
|
|
|
496
465
|
|
|
466
|
+
/**
|
|
467
|
+
* Encode a rest event with tuplet notation start/stop
|
|
468
|
+
*/
|
|
469
|
+
const encodeRestWithTuplet = (
|
|
470
|
+
event: RestEvent,
|
|
471
|
+
voice: number,
|
|
472
|
+
staff: number,
|
|
473
|
+
level: number,
|
|
474
|
+
isFirst: boolean,
|
|
475
|
+
isLast: boolean
|
|
476
|
+
): string => {
|
|
477
|
+
let xml = `${indent(level)}<note>\n`;
|
|
478
|
+
|
|
479
|
+
xml += `${indent(level + 1)}<rest`;
|
|
480
|
+
if (event.fullMeasure) {
|
|
481
|
+
xml += ' measure="yes"';
|
|
482
|
+
}
|
|
483
|
+
xml += '/>\n';
|
|
484
|
+
|
|
485
|
+
xml += encodeDuration(event.duration, level + 1);
|
|
486
|
+
|
|
487
|
+
xml += `${indent(level + 1)}<voice>${voice}</voice>\n`;
|
|
488
|
+
|
|
489
|
+
if (staff > 0) {
|
|
490
|
+
xml += `${indent(level + 1)}<staff>${staff}</staff>\n`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Add tuplet notations
|
|
494
|
+
xml += `${indent(level + 1)}<notations>\n`;
|
|
495
|
+
if (isFirst) {
|
|
496
|
+
xml += `${indent(level + 2)}<tuplet type="start"/>\n`;
|
|
497
|
+
}
|
|
498
|
+
if (isLast) {
|
|
499
|
+
xml += `${indent(level + 2)}<tuplet type="stop"/>\n`;
|
|
500
|
+
}
|
|
501
|
+
xml += `${indent(level + 1)}</notations>\n`;
|
|
502
|
+
|
|
503
|
+
xml += `${indent(level)}</note>\n`;
|
|
504
|
+
|
|
505
|
+
return xml;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
|
|
497
509
|
/**
|
|
498
510
|
* Encode direction element (dynamics, tempo, etc.)
|
|
499
511
|
*/
|
|
@@ -627,10 +639,11 @@ const encodeHarmony = (event: HarmonyEvent, level: number): string => {
|
|
|
627
639
|
|
|
628
640
|
|
|
629
641
|
/**
|
|
630
|
-
* Encode a complete measure
|
|
642
|
+
* Encode a complete measure for a single part
|
|
631
643
|
*/
|
|
632
644
|
const encodeMeasure = (
|
|
633
645
|
measure: Measure,
|
|
646
|
+
partIndex: number,
|
|
634
647
|
measureNumber: number,
|
|
635
648
|
isFirst: boolean,
|
|
636
649
|
prevKey: KeySignature | undefined,
|
|
@@ -639,32 +652,33 @@ const encodeMeasure = (
|
|
|
639
652
|
): string => {
|
|
640
653
|
let xml = `${indent(level)}<measure number="${measureNumber}">\n`;
|
|
641
654
|
|
|
655
|
+
const part = measure.parts[partIndex];
|
|
656
|
+
if (!part) {
|
|
657
|
+
xml += `${indent(level)}</measure>\n`;
|
|
658
|
+
return xml;
|
|
659
|
+
}
|
|
660
|
+
|
|
642
661
|
// Determine if we need attributes
|
|
643
662
|
const needAttributes = isFirst ||
|
|
644
663
|
(measure.key && JSON.stringify(measure.key) !== JSON.stringify(prevKey)) ||
|
|
645
664
|
(measure.timeSig && JSON.stringify(measure.timeSig) !== JSON.stringify(prevTime));
|
|
646
665
|
|
|
647
|
-
// Find max staff number
|
|
666
|
+
// Find max staff number within this part
|
|
648
667
|
let maxStaff = 1;
|
|
649
|
-
for (const
|
|
650
|
-
|
|
651
|
-
maxStaff = Math.max(maxStaff, voice.staff || 1);
|
|
652
|
-
}
|
|
668
|
+
for (const voice of part.voices) {
|
|
669
|
+
maxStaff = Math.max(maxStaff, voice.staff || 1);
|
|
653
670
|
}
|
|
654
671
|
|
|
655
672
|
// Encode attributes if needed
|
|
656
673
|
if (needAttributes) {
|
|
657
|
-
// Find clef from first voice
|
|
674
|
+
// Find clef from first voice of this part
|
|
658
675
|
let clef: Clef | undefined;
|
|
659
|
-
for (const
|
|
660
|
-
for (const
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
break;
|
|
665
|
-
}
|
|
676
|
+
for (const voice of part.voices) {
|
|
677
|
+
for (const event of voice.events) {
|
|
678
|
+
if (event.type === 'context' && event.clef) {
|
|
679
|
+
clef = event.clef;
|
|
680
|
+
break;
|
|
666
681
|
}
|
|
667
|
-
if (clef) break;
|
|
668
682
|
}
|
|
669
683
|
if (clef) break;
|
|
670
684
|
}
|
|
@@ -678,95 +692,124 @@ const encodeMeasure = (
|
|
|
678
692
|
});
|
|
679
693
|
}
|
|
680
694
|
|
|
681
|
-
// Encode voices
|
|
695
|
+
// Encode voices (voice numbering starts at 1 for each part)
|
|
682
696
|
let voiceNum = 1;
|
|
683
697
|
let currentPosition = 0;
|
|
684
698
|
|
|
685
|
-
for (const
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
let voicePosition = 0;
|
|
689
|
-
|
|
690
|
-
// Backup if needed
|
|
691
|
-
if (currentPosition > 0 && voiceNum > 1) {
|
|
692
|
-
xml += `${indent(level + 1)}<backup>\n`;
|
|
693
|
-
xml += `${indent(level + 2)}<duration>${currentPosition}</duration>\n`;
|
|
694
|
-
xml += `${indent(level + 1)}</backup>\n`;
|
|
695
|
-
voicePosition = 0;
|
|
696
|
-
}
|
|
699
|
+
for (const voice of part.voices) {
|
|
700
|
+
let currentStaff = voice.staff || 1;
|
|
701
|
+
let voicePosition = 0;
|
|
697
702
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
if (directionMarks.length > 0) {
|
|
706
|
-
xml += encodeDirection(directionMarks, level + 1);
|
|
707
|
-
}
|
|
703
|
+
// Backup if needed
|
|
704
|
+
if (currentPosition > 0 && voiceNum > 1) {
|
|
705
|
+
xml += `${indent(level + 1)}<backup>\n`;
|
|
706
|
+
xml += `${indent(level + 2)}<duration>${currentPosition}</duration>\n`;
|
|
707
|
+
xml += `${indent(level + 1)}</backup>\n`;
|
|
708
|
+
voicePosition = 0;
|
|
709
|
+
}
|
|
708
710
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
pitches: [event.pitches[i]],
|
|
719
|
-
};
|
|
720
|
-
xml += encodeNote(chordEvent, voiceNum, staff, level + 1, true);
|
|
721
|
-
}
|
|
722
|
-
break;
|
|
711
|
+
for (const event of voice.events) {
|
|
712
|
+
switch (event.type) {
|
|
713
|
+
case 'note': {
|
|
714
|
+
// Check for direction marks (dynamics, hairpins, pedals)
|
|
715
|
+
const directionMarks = event.marks?.filter(m =>
|
|
716
|
+
m.markType === 'dynamic' || m.markType === 'hairpin' || m.markType === 'pedal'
|
|
717
|
+
) || [];
|
|
718
|
+
if (directionMarks.length > 0) {
|
|
719
|
+
xml += encodeDirection(directionMarks, level + 1);
|
|
723
720
|
}
|
|
724
721
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
722
|
+
// Encode main note
|
|
723
|
+
xml += encodeNote(event, voiceNum, currentStaff, level + 1);
|
|
724
|
+
const dur = calculateDuration(event.duration);
|
|
725
|
+
voicePosition += dur;
|
|
726
|
+
|
|
727
|
+
// Encode chord notes
|
|
728
|
+
for (let i = 1; i < event.pitches.length; i++) {
|
|
729
|
+
const chordEvent: NoteEvent = {
|
|
730
|
+
...event,
|
|
731
|
+
pitches: [event.pitches[i]],
|
|
732
|
+
};
|
|
733
|
+
xml += encodeNote(chordEvent, voiceNum, currentStaff, level + 1, true);
|
|
730
734
|
}
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
731
737
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
+
case 'rest': {
|
|
739
|
+
xml += encodeRest(event, voiceNum, currentStaff, level + 1);
|
|
740
|
+
const dur = calculateDuration(event.duration);
|
|
741
|
+
voicePosition += dur;
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
case 'context': {
|
|
746
|
+
if (event.tempo) {
|
|
747
|
+
xml += encodeTempo(event.tempo, level + 1);
|
|
748
|
+
}
|
|
749
|
+
if (event.staff) {
|
|
750
|
+
currentStaff = event.staff;
|
|
738
751
|
}
|
|
752
|
+
// Other context changes are handled in attributes
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
739
755
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
756
|
+
case 'tuplet': {
|
|
757
|
+
const tupletEvents = event.events;
|
|
758
|
+
for (let ti = 0; ti < tupletEvents.length; ti++) {
|
|
759
|
+
const subEvent = tupletEvents[ti];
|
|
760
|
+
// Set tuplet ratio on duration so encodeDuration emits <time-modification>
|
|
761
|
+
const originalTuplet = subEvent.duration.tuplet;
|
|
762
|
+
subEvent.duration.tuplet = event.ratio;
|
|
763
|
+
|
|
764
|
+
const isFirst = ti === 0;
|
|
765
|
+
const isLast = ti === tupletEvents.length - 1;
|
|
766
|
+
|
|
767
|
+
if (subEvent.type === 'note') {
|
|
768
|
+
// Add tuplet notation marks
|
|
769
|
+
const tupletMarks: Mark[] = [];
|
|
770
|
+
if (isFirst) tupletMarks.push({ markType: 'tuplet', start: true } as any);
|
|
771
|
+
if (isLast) tupletMarks.push({ markType: 'tuplet', start: false } as any);
|
|
772
|
+
|
|
773
|
+
if (tupletMarks.length > 0) {
|
|
774
|
+
const origMarks = subEvent.marks;
|
|
775
|
+
subEvent.marks = [...(subEvent.marks || []), ...tupletMarks];
|
|
776
|
+
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
|
|
777
|
+
subEvent.marks = origMarks;
|
|
778
|
+
} else {
|
|
779
|
+
xml += encodeNote(subEvent, voiceNum, currentStaff, level + 1);
|
|
750
780
|
}
|
|
781
|
+
const dur = calculateDuration(subEvent.duration);
|
|
782
|
+
voicePosition += dur;
|
|
783
|
+
} else if (subEvent.type === 'rest') {
|
|
784
|
+
if (isFirst || isLast) {
|
|
785
|
+
xml += encodeRestWithTuplet(subEvent, voiceNum, currentStaff, level + 1, isFirst, isLast);
|
|
786
|
+
} else {
|
|
787
|
+
xml += encodeRest(subEvent, voiceNum, currentStaff, level + 1);
|
|
788
|
+
}
|
|
789
|
+
const dur = calculateDuration(subEvent.duration);
|
|
790
|
+
voicePosition += dur;
|
|
751
791
|
}
|
|
752
|
-
break;
|
|
753
|
-
}
|
|
754
792
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
break;
|
|
793
|
+
// Restore original tuplet value
|
|
794
|
+
subEvent.duration.tuplet = originalTuplet;
|
|
758
795
|
}
|
|
796
|
+
break;
|
|
797
|
+
}
|
|
759
798
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
}
|
|
799
|
+
case 'barline': {
|
|
800
|
+
xml += encodeBarline(event, level + 1);
|
|
801
|
+
break;
|
|
764
802
|
}
|
|
765
|
-
}
|
|
766
803
|
|
|
767
|
-
|
|
768
|
-
|
|
804
|
+
case 'harmony': {
|
|
805
|
+
xml += encodeHarmony(event, level + 1);
|
|
806
|
+
break;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
769
809
|
}
|
|
810
|
+
|
|
811
|
+
currentPosition = Math.max(currentPosition, voicePosition);
|
|
812
|
+
voiceNum++;
|
|
770
813
|
}
|
|
771
814
|
|
|
772
815
|
xml += `${indent(level)}</measure>\n`;
|
|
@@ -823,30 +866,42 @@ export const encode = (doc: LilyletDoc): string => {
|
|
|
823
866
|
xml += encodeMetadata(doc.metadata, 1);
|
|
824
867
|
}
|
|
825
868
|
|
|
826
|
-
//
|
|
869
|
+
// Determine number of parts from first measure
|
|
870
|
+
const numParts = doc.measures.length > 0 ? doc.measures[0].parts.length : 1;
|
|
871
|
+
|
|
872
|
+
// Part list
|
|
827
873
|
xml += `${indent(1)}<part-list>\n`;
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
874
|
+
for (let pi = 0; pi < numParts; pi++) {
|
|
875
|
+
const partId = `P${pi + 1}`;
|
|
876
|
+
const partName = doc.measures[0]?.parts[pi]?.name
|
|
877
|
+
|| (numParts === 1 ? (doc.metadata?.title ? escapeXml(doc.metadata.title) : 'Music') : `Part ${pi + 1}`);
|
|
878
|
+
xml += `${indent(2)}<score-part id="${partId}">\n`;
|
|
879
|
+
xml += `${indent(3)}<part-name>${escapeXml(partName)}</part-name>\n`;
|
|
880
|
+
xml += `${indent(2)}</score-part>\n`;
|
|
881
|
+
}
|
|
831
882
|
xml += `${indent(1)}</part-list>\n`;
|
|
832
883
|
|
|
833
|
-
//
|
|
834
|
-
|
|
884
|
+
// Encode each part
|
|
885
|
+
for (let pi = 0; pi < numParts; pi++) {
|
|
886
|
+
const partId = `P${pi + 1}`;
|
|
887
|
+
xml += `${indent(1)}<part id="${partId}">\n`;
|
|
835
888
|
|
|
836
|
-
|
|
837
|
-
|
|
889
|
+
let prevKey: KeySignature | undefined;
|
|
890
|
+
let prevTime: Fraction | undefined;
|
|
838
891
|
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
892
|
+
for (let i = 0; i < doc.measures.length; i++) {
|
|
893
|
+
const measure = doc.measures[i];
|
|
894
|
+
const isFirst = i === 0;
|
|
842
895
|
|
|
843
|
-
|
|
896
|
+
xml += encodeMeasure(measure, pi, i + 1, isFirst, prevKey, prevTime, 2);
|
|
897
|
+
|
|
898
|
+
if (measure.key) prevKey = measure.key;
|
|
899
|
+
if (measure.timeSig) prevTime = measure.timeSig;
|
|
900
|
+
}
|
|
844
901
|
|
|
845
|
-
|
|
846
|
-
if (measure.timeSig) prevTime = measure.timeSig;
|
|
902
|
+
xml += `${indent(1)}</part>\n`;
|
|
847
903
|
}
|
|
848
904
|
|
|
849
|
-
xml += `${indent(1)}</part>\n`;
|
|
850
905
|
xml += '</score-partwise>\n';
|
|
851
906
|
|
|
852
907
|
return xml;
|
|
@@ -158,10 +158,13 @@ export const convertPitch = (
|
|
|
158
158
|
};
|
|
159
159
|
};
|
|
160
160
|
|
|
161
|
-
// ============ Duration
|
|
161
|
+
// ============ Duration Constants & Mappings ============
|
|
162
|
+
|
|
163
|
+
// Standard divisions per quarter note (shared by encoder/decoder)
|
|
164
|
+
export const DIVISIONS = 4;
|
|
162
165
|
|
|
163
166
|
// MusicXML note type to division (1=whole, 2=half, 4=quarter, etc.)
|
|
164
|
-
const TYPE_TO_DIVISION: Record<string, number> = {
|
|
167
|
+
export const TYPE_TO_DIVISION: Record<string, number> = {
|
|
165
168
|
maxima: 0.125,
|
|
166
169
|
long: 0.25,
|
|
167
170
|
breve: 0.5,
|
|
@@ -178,6 +181,43 @@ const TYPE_TO_DIVISION: Record<string, number> = {
|
|
|
178
181
|
'1024th': 1024,
|
|
179
182
|
};
|
|
180
183
|
|
|
184
|
+
// Division to MusicXML note type (inverse of TYPE_TO_DIVISION)
|
|
185
|
+
export const DIVISION_TO_TYPE: Record<number, string> = Object.fromEntries(
|
|
186
|
+
Object.entries(TYPE_TO_DIVISION).map(([type, div]) => [div, type])
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Calculate duration in MusicXML divisions.
|
|
191
|
+
* Shared by encoder (with DIVISIONS=4) and potentially decoder.
|
|
192
|
+
*
|
|
193
|
+
* Duration.tuplet is in Lilylet ratio semantics:
|
|
194
|
+
* \times 2/3 → {numerator:2, denominator:3} → multiply by 2/3
|
|
195
|
+
*/
|
|
196
|
+
export const calculateDuration = (duration: Duration, divisions: number = DIVISIONS): number => {
|
|
197
|
+
// Base duration: divisions * (4 / division)
|
|
198
|
+
// e.g., quarter (4) = divisions * 1
|
|
199
|
+
// half (2) = divisions * 2
|
|
200
|
+
// eighth (8) = divisions * 0.5
|
|
201
|
+
let dur = divisions * (4 / duration.division);
|
|
202
|
+
|
|
203
|
+
// Apply dots
|
|
204
|
+
if (duration.dots) {
|
|
205
|
+
let dotValue = dur / 2;
|
|
206
|
+
for (let i = 0; i < duration.dots; i++) {
|
|
207
|
+
dur += dotValue;
|
|
208
|
+
dotValue /= 2;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Apply tuplet ratio: Lilylet ratio num/den means multiply by num/den
|
|
213
|
+
// e.g., \times 2/3 means each note's actual duration = written * 2/3
|
|
214
|
+
if (duration.tuplet) {
|
|
215
|
+
dur = dur * duration.tuplet.numerator / duration.tuplet.denominator;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return Math.round(dur);
|
|
219
|
+
};
|
|
220
|
+
|
|
181
221
|
/**
|
|
182
222
|
* Convert MusicXML duration to Lilylet Duration
|
|
183
223
|
*
|
|
@@ -218,9 +258,11 @@ export const convertDuration = (
|
|
|
218
258
|
};
|
|
219
259
|
|
|
220
260
|
if (timeModification) {
|
|
261
|
+
// Store as Lilylet ratio: normalNotes/actualNotes
|
|
262
|
+
// MusicXML actual=3, normal=2 (triplet) → Lilylet ratio {num:2, den:3}
|
|
221
263
|
result.tuplet = {
|
|
222
|
-
numerator: timeModification.
|
|
223
|
-
denominator: timeModification.
|
|
264
|
+
numerator: timeModification.normalNotes,
|
|
265
|
+
denominator: timeModification.actualNotes,
|
|
224
266
|
};
|
|
225
267
|
}
|
|
226
268
|
|
|
@@ -771,6 +771,9 @@ const serializeMetadata = (metadata: any): string => {
|
|
|
771
771
|
if (metadata.lyricist) {
|
|
772
772
|
lines.push('[lyricist "' + escapeString(metadata.lyricist) + '"]');
|
|
773
773
|
}
|
|
774
|
+
if (metadata.autoBeam) {
|
|
775
|
+
lines.push('[auto-beam "' + escapeString(metadata.autoBeam) + '"]');
|
|
776
|
+
}
|
|
774
777
|
|
|
775
778
|
return lines.join('\n');
|
|
776
779
|
};
|
package/source/lilylet/types.ts
CHANGED