@k-l-lambda/lilylet 0.1.76 → 0.1.78
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/grammar.jison.js +85 -79
- package/lib/lilylet/abcDecoder.js +167 -0
- package/lib/lilylet/grammar.jison.js +120 -109
- package/lib/lilylet/highlight.js +3 -1
- package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +3 -1
- package/lib/lilylet/meiEncoder.js +62 -7
- package/lib/lilylet/musicXmlDecoder.js +119 -24
- package/lib/lilylet/musicXmlEncoder.js +85 -18
- package/lib/lilylet/musicXmlTypes.d.ts +3 -1
- package/lib/lilylet/musicXmlUtils.js +1 -1
- package/lib/lilylet/serializer.js +14 -0
- package/lib/lilylet/types.d.ts +6 -2
- package/lib/lilylet/types.js +1 -0
- package/package.json +3 -1
- package/source/abc/abc.jison +8 -4
- package/source/abc/grammar.jison.js +85 -79
- package/source/lilylet/abcDecoder.ts +189 -0
- package/source/lilylet/grammar.jison.js +120 -109
- package/source/lilylet/highlight.ts +3 -1
- package/source/lilylet/lilylet.jison +10 -1
- package/source/lilylet/measureLayoutFromXml.ts +3 -1
- package/source/lilylet/meiEncoder.ts +83 -7
- package/source/lilylet/musicXmlDecoder.ts +117 -23
- package/source/lilylet/musicXmlEncoder.ts +89 -16
- package/source/lilylet/musicXmlTypes.ts +5 -1
- package/source/lilylet/musicXmlUtils.ts +1 -1
- package/source/lilylet/serializer.ts +14 -0
- package/source/lilylet/types.ts +10 -1
|
@@ -419,6 +419,8 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
419
419
|
fingerings: { finger: number; placement?: 'above' | 'below' }[];
|
|
420
420
|
navigation?: 'coda' | 'segno';
|
|
421
421
|
markups: { content: string; placement?: 'above' | 'below' }[];
|
|
422
|
+
breath: boolean;
|
|
423
|
+
glissando: boolean;
|
|
422
424
|
} => {
|
|
423
425
|
const result = {
|
|
424
426
|
artics: [] as { type: string; placement?: 'above' | 'below' }[],
|
|
@@ -441,6 +443,8 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
441
443
|
fingerings: [] as { finger: number; placement?: 'above' | 'below' }[],
|
|
442
444
|
navigation: undefined as 'coda' | 'segno' | undefined,
|
|
443
445
|
markups: [] as { content: string; placement?: 'above' | 'below' }[],
|
|
446
|
+
breath: false,
|
|
447
|
+
glissando: false,
|
|
444
448
|
};
|
|
445
449
|
|
|
446
450
|
if (!marks) return result;
|
|
@@ -472,6 +476,8 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
472
476
|
result.mordent = 'lower';
|
|
473
477
|
} else if (mark.type === OrnamentType.prall) {
|
|
474
478
|
result.mordent = 'upper';
|
|
479
|
+
} else if (mark.type === OrnamentType.breath) {
|
|
480
|
+
result.breath = true;
|
|
475
481
|
}
|
|
476
482
|
break;
|
|
477
483
|
case 'dynamic': {
|
|
@@ -537,6 +543,9 @@ const extractMarkOptions = (marks?: Mark[]): {
|
|
|
537
543
|
placement: (mark as { placement?: 'above' | 'below' }).placement,
|
|
538
544
|
});
|
|
539
545
|
break;
|
|
546
|
+
case 'glissando':
|
|
547
|
+
result.glissando = true;
|
|
548
|
+
break;
|
|
540
549
|
}
|
|
541
550
|
|
|
542
551
|
// Tremolo (special case - from parser internal mark)
|
|
@@ -570,6 +579,8 @@ interface NoteEventResult {
|
|
|
570
579
|
fingerings: { finger: number; placement?: 'above' | 'below' }[];
|
|
571
580
|
navigation?: 'coda' | 'segno';
|
|
572
581
|
markups: { content: string; placement?: 'above' | 'below' }[];
|
|
582
|
+
breath: boolean;
|
|
583
|
+
glissando: boolean;
|
|
573
584
|
}
|
|
574
585
|
|
|
575
586
|
// Convert NoteEvent to MEI
|
|
@@ -638,6 +649,8 @@ const noteEventToMEI = (
|
|
|
638
649
|
fingerings: markOptions.fingerings,
|
|
639
650
|
navigation: markOptions.navigation,
|
|
640
651
|
markups: markOptions.markups,
|
|
652
|
+
breath: markOptions.breath,
|
|
653
|
+
glissando: markOptions.glissando,
|
|
641
654
|
};
|
|
642
655
|
}
|
|
643
656
|
|
|
@@ -701,6 +714,8 @@ const noteEventToMEI = (
|
|
|
701
714
|
fingerings: markOptions.fingerings,
|
|
702
715
|
navigation: markOptions.navigation,
|
|
703
716
|
markups: markOptions.markups,
|
|
717
|
+
breath: markOptions.breath,
|
|
718
|
+
glissando: markOptions.glissando,
|
|
704
719
|
};
|
|
705
720
|
};
|
|
706
721
|
|
|
@@ -996,6 +1011,15 @@ interface TrillRef {
|
|
|
996
1011
|
startid: string;
|
|
997
1012
|
}
|
|
998
1013
|
|
|
1014
|
+
interface BreathRef {
|
|
1015
|
+
startid: string; // the note the breath follows
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
interface GlissRef {
|
|
1019
|
+
startid: string;
|
|
1020
|
+
endid: string;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
999
1023
|
interface MordentRef {
|
|
1000
1024
|
startid: string;
|
|
1001
1025
|
form?: 'upper'; // prall = upper mordent
|
|
@@ -1044,6 +1068,7 @@ interface SlurSpan {
|
|
|
1044
1068
|
// Tie state for cross-measure ties - maps staff:layer to pending pitches
|
|
1045
1069
|
type TieState = Record<string, Pitch[]>;
|
|
1046
1070
|
type SlurState = Record<string, string[]>; // voice key -> open slur startIds (stack, oldest first)
|
|
1071
|
+
type GlissState = Record<string, string | null>; // voice key -> pending glissando start id (awaiting next-measure target)
|
|
1047
1072
|
type HairpinState = Record<string, { form: 'cres' | 'dim'; startId: string } | null>; // voice key -> pending hairpin
|
|
1048
1073
|
|
|
1049
1074
|
// Pending octave span for cross-measure continuation
|
|
@@ -1069,6 +1094,8 @@ interface LayerResult {
|
|
|
1069
1094
|
arpeggios: ArpegRef[];
|
|
1070
1095
|
fermatas: FermataRef[];
|
|
1071
1096
|
trills: TrillRef[];
|
|
1097
|
+
breaths: BreathRef[];
|
|
1098
|
+
glissandos: GlissRef[];
|
|
1072
1099
|
mordents: MordentRef[];
|
|
1073
1100
|
turns: TurnRef[];
|
|
1074
1101
|
dynamics: DynamRef[];
|
|
@@ -1079,6 +1106,7 @@ interface LayerResult {
|
|
|
1079
1106
|
markups: MarkupRef[];
|
|
1080
1107
|
pendingTiePitches: Pitch[]; // For cross-measure tie tracking
|
|
1081
1108
|
pendingSlur: string[]; // For cross-measure slur tracking (all open slur startIds)
|
|
1109
|
+
pendingGliss: string | null; // For cross-measure glissando (start note awaiting its target in the next measure)
|
|
1082
1110
|
pendingHairpin: { form: 'cres' | 'dim'; startId: string } | null; // For cross-measure hairpin tracking
|
|
1083
1111
|
pendingOctave: PendingOctave | null; // For cross-measure ottava span tracking
|
|
1084
1112
|
ottavaExplicitlyClosed: boolean; // True if ottava was closed by explicit \ottava #0 in this layer
|
|
@@ -1117,7 +1145,7 @@ const getEventBeamMarks = (event: NoteEvent | RestEvent | TupletEvent | TimesEve
|
|
|
1117
1145
|
};
|
|
1118
1146
|
|
|
1119
1147
|
// Encode a layer (voice)
|
|
1120
|
-
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 => {
|
|
1148
|
+
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, initialGliss: string | null = null): LayerResult => {
|
|
1121
1149
|
const layerId = generateId("layer");
|
|
1122
1150
|
let xml = `${indent}<layer xml:id="${layerId}" n="${layerN}">\n`;
|
|
1123
1151
|
|
|
@@ -1197,6 +1225,12 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1197
1225
|
// Track ornament refs
|
|
1198
1226
|
const fermatas: FermataRef[] = [];
|
|
1199
1227
|
const trills: TrillRef[] = [];
|
|
1228
|
+
const breaths: BreathRef[] = [];
|
|
1229
|
+
// Glissando: a note-to-note slide. Record the start note id and close it on the
|
|
1230
|
+
// NEXT encoded note (endid), mirroring slur start→end pairing. A trailing
|
|
1231
|
+
// glissando with no following note in the layer is dropped (no target).
|
|
1232
|
+
const glissandos: GlissRef[] = [];
|
|
1233
|
+
let pendingGlissStart: string | null = initialGliss;
|
|
1200
1234
|
const mordents: MordentRef[] = [];
|
|
1201
1235
|
const turns: TurnRef[] = [];
|
|
1202
1236
|
const dynamics: DynamRef[] = [];
|
|
@@ -1374,6 +1408,19 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1374
1408
|
if (result.trill) {
|
|
1375
1409
|
trills.push({ startid: result.elementId });
|
|
1376
1410
|
}
|
|
1411
|
+
// Breath mark follows this note.
|
|
1412
|
+
if (result.breath) {
|
|
1413
|
+
breaths.push({ startid: result.elementId });
|
|
1414
|
+
}
|
|
1415
|
+
// Glissando: close a pending start onto this note, then open a new one
|
|
1416
|
+
// if this note itself carries a glissando.
|
|
1417
|
+
if (pendingGlissStart) {
|
|
1418
|
+
glissandos.push({ startid: pendingGlissStart, endid: result.elementId });
|
|
1419
|
+
pendingGlissStart = null;
|
|
1420
|
+
}
|
|
1421
|
+
if (result.glissando) {
|
|
1422
|
+
pendingGlissStart = result.elementId;
|
|
1423
|
+
}
|
|
1377
1424
|
if (result.mordent) {
|
|
1378
1425
|
mordents.push({
|
|
1379
1426
|
startid: result.elementId,
|
|
@@ -1645,7 +1692,7 @@ const encodeLayer = (voice: Voice, layerN: number, indent: string, initialTiePit
|
|
|
1645
1692
|
}
|
|
1646
1693
|
|
|
1647
1694
|
xml += `${indent}</layer>\n`;
|
|
1648
|
-
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 };
|
|
1695
|
+
return { xml, hairpins, pedals, octaves, slurs, arpeggios, fermatas, trills, breaths, glissandos, mordents, turns, dynamics, fingerings, navigations, harmonies, barlines, markups, pendingTiePitches, pendingSlur: openSlurs.map(s => s.startId), pendingGliss: pendingGlissStart, pendingHairpin, pendingOctave, ottavaExplicitlyClosed, endingClef: currentClef, lastNoteId, currentOttavaShift, octaveEndReplacements };
|
|
1649
1696
|
};
|
|
1650
1697
|
|
|
1651
1698
|
// Staff result type
|
|
@@ -1658,6 +1705,8 @@ interface StaffResult {
|
|
|
1658
1705
|
arpeggios: ArpegRef[];
|
|
1659
1706
|
fermatas: FermataRef[];
|
|
1660
1707
|
trills: TrillRef[];
|
|
1708
|
+
breaths: BreathRef[];
|
|
1709
|
+
glissandos: GlissRef[];
|
|
1661
1710
|
mordents: MordentRef[];
|
|
1662
1711
|
turns: TurnRef[];
|
|
1663
1712
|
dynamics: DynamRef[];
|
|
@@ -1668,6 +1717,7 @@ interface StaffResult {
|
|
|
1668
1717
|
markups: MarkupRef[];
|
|
1669
1718
|
pendingTies: TieState; // For cross-measure tie tracking
|
|
1670
1719
|
pendingSlurs: SlurState; // For cross-measure slur tracking
|
|
1720
|
+
pendingGlisses: GlissState; // For cross-measure glissando tracking
|
|
1671
1721
|
pendingHairpins: HairpinState; // For cross-measure hairpin tracking
|
|
1672
1722
|
pendingOctaves: OttavaState; // For cross-measure ottava span tracking
|
|
1673
1723
|
ottavaExplicitlyClosed: Record<string, boolean>; // Track which layers had ottava explicitly closed
|
|
@@ -1677,7 +1727,7 @@ interface StaffResult {
|
|
|
1677
1727
|
}
|
|
1678
1728
|
|
|
1679
1729
|
// Encode a staff
|
|
1680
|
-
const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, ottavaState: OttavaState = {}, keyFifths: number = 0, initialClef?: Clef): StaffResult => {
|
|
1730
|
+
const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState: TieState = {}, slurState: SlurState = {}, hairpinState: HairpinState = {}, ottavaState: OttavaState = {}, keyFifths: number = 0, initialClef?: Clef, glissState: GlissState = {}): StaffResult => {
|
|
1681
1731
|
const staffId = generateId("staff");
|
|
1682
1732
|
let xml = `${indent}<staff xml:id="${staffId}" n="${staffN}">\n`;
|
|
1683
1733
|
const allHairpins: HairpinSpan[] = [];
|
|
@@ -1687,6 +1737,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1687
1737
|
const allArpeggios: ArpegRef[] = [];
|
|
1688
1738
|
const allFermatas: FermataRef[] = [];
|
|
1689
1739
|
const allTrills: TrillRef[] = [];
|
|
1740
|
+
const allBreaths: BreathRef[] = [];
|
|
1741
|
+
const allGlissandos: GlissRef[] = [];
|
|
1690
1742
|
const allMordents: MordentRef[] = [];
|
|
1691
1743
|
const allTurns: TurnRef[] = [];
|
|
1692
1744
|
const allDynamics: DynamRef[] = [];
|
|
@@ -1697,6 +1749,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1697
1749
|
const allMarkups: MarkupRef[] = [];
|
|
1698
1750
|
const pendingTies: TieState = {};
|
|
1699
1751
|
const pendingSlurs: SlurState = {};
|
|
1752
|
+
const pendingGlisses: GlissState = {};
|
|
1700
1753
|
const pendingHairpins: HairpinState = {};
|
|
1701
1754
|
const pendingOctaves: OttavaState = {};
|
|
1702
1755
|
const ottavaExplicitlyClosed: Record<string, boolean> = {};
|
|
@@ -1714,7 +1767,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1714
1767
|
const initialSlur = slurState[tieKey] || [];
|
|
1715
1768
|
const initialHairpin = hairpinState[tieKey] || null;
|
|
1716
1769
|
const initialOctave = ottavaState[tieKey] || null;
|
|
1717
|
-
const
|
|
1770
|
+
const initialGliss = glissState[tieKey] || null;
|
|
1771
|
+
const result = encodeLayer(voice, layerN, indent + ' ', initialTies, keyFifths, endingClef, initialSlur, initialHairpin, initialOctave, initialGliss);
|
|
1718
1772
|
xml += result.xml;
|
|
1719
1773
|
allHairpins.push(...result.hairpins);
|
|
1720
1774
|
allPedals.push(...result.pedals);
|
|
@@ -1723,6 +1777,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1723
1777
|
allArpeggios.push(...result.arpeggios);
|
|
1724
1778
|
allFermatas.push(...result.fermatas);
|
|
1725
1779
|
allTrills.push(...result.trills);
|
|
1780
|
+
allBreaths.push(...result.breaths);
|
|
1781
|
+
allGlissandos.push(...result.glissandos);
|
|
1726
1782
|
allMordents.push(...result.mordents);
|
|
1727
1783
|
allTurns.push(...result.turns);
|
|
1728
1784
|
allDynamics.push(...result.dynamics);
|
|
@@ -1740,6 +1796,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1740
1796
|
// Always record (even empty) so a measure that closed all its slurs
|
|
1741
1797
|
// clears the carried stack rather than leaving a stale open slur.
|
|
1742
1798
|
pendingSlurs[tieKey] = result.pendingSlur;
|
|
1799
|
+
pendingGlisses[tieKey] = result.pendingGliss;
|
|
1743
1800
|
// Track pending hairpins for this layer
|
|
1744
1801
|
if (result.pendingHairpin) {
|
|
1745
1802
|
pendingHairpins[tieKey] = result.pendingHairpin;
|
|
@@ -1771,6 +1828,8 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1771
1828
|
arpeggios: allArpeggios,
|
|
1772
1829
|
fermatas: allFermatas,
|
|
1773
1830
|
trills: allTrills,
|
|
1831
|
+
breaths: allBreaths,
|
|
1832
|
+
glissandos: allGlissandos,
|
|
1774
1833
|
mordents: allMordents,
|
|
1775
1834
|
turns: allTurns,
|
|
1776
1835
|
dynamics: allDynamics,
|
|
@@ -1781,6 +1840,7 @@ const encodeStaff = (voices: Voice[], staffN: number, indent: string, tieState:
|
|
|
1781
1840
|
markups: allMarkups,
|
|
1782
1841
|
pendingTies,
|
|
1783
1842
|
pendingSlurs,
|
|
1843
|
+
pendingGlisses,
|
|
1784
1844
|
pendingHairpins,
|
|
1785
1845
|
pendingOctaves,
|
|
1786
1846
|
ottavaExplicitlyClosed,
|
|
@@ -1903,7 +1963,7 @@ const BARLINE_TO_MEI: Record<string, string> = {
|
|
|
1903
1963
|
|
|
1904
1964
|
// Encode a measure
|
|
1905
1965
|
// encodeMeasure accepts mutable tieState, slurState, hairpinState, ottavaState and clefState that persist across measures
|
|
1906
|
-
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}, octaveEndReplacements: Record<string, string> = {}, measureIds?: string[]): string => {
|
|
1966
|
+
const encodeMeasure = (measure: Measure, measureN: number, indent: string, totalStaves: number, tieState: TieState, slurState: SlurState, hairpinState: HairpinState, ottavaState: OttavaState, keyFifths: number = 0, partInfos: PartInfo[] = [], clefState: ClefState = {}, octaveEndReplacements: Record<string, string> = {}, measureIds?: string[], glissState: GlissState = {}): string => {
|
|
1907
1967
|
const measureId = generateId("measure");
|
|
1908
1968
|
if (measureIds) measureIds.push(measureId);
|
|
1909
1969
|
let staffContent = ''; // Build staff content first, then add measure tag with barline
|
|
@@ -1914,6 +1974,8 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1914
1974
|
const allArpeggios: ArpegRef[] = [];
|
|
1915
1975
|
const allFermatas: FermataRef[] = [];
|
|
1916
1976
|
const allTrills: TrillRef[] = [];
|
|
1977
|
+
const allBreaths: BreathRef[] = [];
|
|
1978
|
+
const allGlissandos: GlissRef[] = [];
|
|
1917
1979
|
const allMordents: MordentRef[] = [];
|
|
1918
1980
|
const allTurns: TurnRef[] = [];
|
|
1919
1981
|
const allDynamics: DynamRef[] = [];
|
|
@@ -1963,7 +2025,7 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1963
2025
|
for (let si = 1; si <= totalStaves; si++) {
|
|
1964
2026
|
const voices = voicesByStaff[si] || [];
|
|
1965
2027
|
const initialClef = clefState[si];
|
|
1966
|
-
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef);
|
|
2028
|
+
const result = encodeStaff(voices, si, indent + ' ', tieState, slurState, hairpinState, ottavaState, keyFifths, initialClef, glissState);
|
|
1967
2029
|
staffContent += result.xml;
|
|
1968
2030
|
allHairpins.push(...result.hairpins);
|
|
1969
2031
|
allPedals.push(...result.pedals);
|
|
@@ -1972,6 +2034,8 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1972
2034
|
allArpeggios.push(...result.arpeggios);
|
|
1973
2035
|
allFermatas.push(...result.fermatas);
|
|
1974
2036
|
allTrills.push(...result.trills);
|
|
2037
|
+
allBreaths.push(...result.breaths);
|
|
2038
|
+
allGlissandos.push(...result.glissandos);
|
|
1975
2039
|
allMordents.push(...result.mordents);
|
|
1976
2040
|
allTurns.push(...result.turns);
|
|
1977
2041
|
allDynamics.push(...result.dynamics);
|
|
@@ -1985,6 +2049,7 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
1985
2049
|
Object.assign(tieState, result.pendingTies);
|
|
1986
2050
|
// Update slur state with pending slurs from this staff
|
|
1987
2051
|
Object.assign(slurState, result.pendingSlurs);
|
|
2052
|
+
Object.assign(glissState, result.pendingGlisses);
|
|
1988
2053
|
// Update hairpin state with pending hairpins from this staff
|
|
1989
2054
|
Object.assign(hairpinState, result.pendingHairpins);
|
|
1990
2055
|
// Update ottava state with pending octaves from this staff.
|
|
@@ -2066,6 +2131,16 @@ const encodeMeasure = (measure: Measure, measureN: number, indent: string, total
|
|
|
2066
2131
|
staffContent += `${indent} <trill xml:id="${generateId('trill')}" startid="#${tr.startid}" />\n`;
|
|
2067
2132
|
}
|
|
2068
2133
|
|
|
2134
|
+
// Generate breath marks (the breath follows its startid note)
|
|
2135
|
+
for (const br of allBreaths) {
|
|
2136
|
+
staffContent += `${indent} <breath xml:id="${generateId('breath')}" startid="#${br.startid}" />\n`;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// Generate glissando spans (note-to-note slide)
|
|
2140
|
+
for (const gl of allGlissandos) {
|
|
2141
|
+
staffContent += `${indent} <gliss xml:id="${generateId('gliss')}" startid="#${gl.startid}" endid="#${gl.endid}" />\n`;
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2069
2144
|
// Generate mordent control events
|
|
2070
2145
|
for (const mord of allMordents) {
|
|
2071
2146
|
const formAttr = mord.form ? ` form="${mord.form}"` : '';
|
|
@@ -2812,6 +2887,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2812
2887
|
|
|
2813
2888
|
// Track slur state across measures for cross-measure slurs
|
|
2814
2889
|
const slurState: SlurState = {};
|
|
2890
|
+
const glissState: GlissState = {};
|
|
2815
2891
|
|
|
2816
2892
|
// Track hairpin state across measures for cross-measure hairpins
|
|
2817
2893
|
const hairpinState: HairpinState = {};
|
|
@@ -2901,7 +2977,7 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2901
2977
|
const openTag = segOpenAt.get(mIndex);
|
|
2902
2978
|
if (openTag) mei += openTag;
|
|
2903
2979
|
const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
|
|
2904
|
-
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2980
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds, glissState);
|
|
2905
2981
|
const closeTag = segCloseAt.get(mIndex);
|
|
2906
2982
|
if (closeTag) mei += closeTag;
|
|
2907
2983
|
});
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
HairpinType,
|
|
26
26
|
PedalType,
|
|
27
27
|
NavigationMarkType,
|
|
28
|
+
OrnamentType,
|
|
28
29
|
Placement,
|
|
29
30
|
BarlineEvent,
|
|
30
31
|
HarmonyEvent,
|
|
@@ -305,12 +306,13 @@ class VoiceTracker {
|
|
|
305
306
|
});
|
|
306
307
|
this.currentStaff.set(voiceNum, staff);
|
|
307
308
|
}
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
309
|
+
// NOTE: do NOT overwrite voice.staff on later notes. voice.staff is the voice's
|
|
310
|
+
// HOME staff — the staff of its first note — matching the parser, which fixes
|
|
311
|
+
// voice.staff to the first leading \staff (so `\staff "1" … \staff "2" …` gives
|
|
312
|
+
// voice.staff=1, with the switch carried by an inline ctx(staff=2) event). The
|
|
313
|
+
// previous code clobbered it to every note's staff, so a voice that ended on a
|
|
314
|
+
// different staff round-tripped with the wrong home staff.
|
|
315
|
+
return this.voices.get(voiceNum)!;
|
|
314
316
|
}
|
|
315
317
|
|
|
316
318
|
addEvent(voiceNum: number, event: Event, duration: number, staff: number = 1): void {
|
|
@@ -430,9 +432,11 @@ const parseNotations = (notationsEl: Element): MusicXmlNotations => {
|
|
|
430
432
|
}
|
|
431
433
|
}
|
|
432
434
|
|
|
433
|
-
// Fermata
|
|
434
|
-
|
|
435
|
-
|
|
435
|
+
// Fermata. The "angled" shape is LilyPond's \shortfermata; everything else
|
|
436
|
+
// (normal/square/unset) is the ordinary \fermata.
|
|
437
|
+
const fermataEl = getElements(notationsEl, 'fermata')[0];
|
|
438
|
+
if (fermataEl) {
|
|
439
|
+
result.fermata = (fermataEl.textContent || '').trim() === 'angled' ? 'short' : 'normal';
|
|
436
440
|
}
|
|
437
441
|
|
|
438
442
|
// Arpeggiate
|
|
@@ -457,6 +461,14 @@ const parseNotations = (notationsEl: Element): MusicXmlNotations => {
|
|
|
457
461
|
};
|
|
458
462
|
}
|
|
459
463
|
|
|
464
|
+
// Glissando / slide — both are note-to-note slide lines. Only the START note
|
|
465
|
+
// carries the mark (LilyPond \glissando auto-connects to the next note), so we
|
|
466
|
+
// record presence of any start; stops are ignored.
|
|
467
|
+
const slideEls = [...getElements(notationsEl, 'glissando'), ...getElements(notationsEl, 'slide')];
|
|
468
|
+
if (slideEls.some(el => getAttribute(el, 'type') === 'start')) {
|
|
469
|
+
result.glissando = true;
|
|
470
|
+
}
|
|
471
|
+
|
|
460
472
|
return result;
|
|
461
473
|
};
|
|
462
474
|
|
|
@@ -726,6 +738,17 @@ const parseDirection = (dirEl: Element): MusicXmlDirection => {
|
|
|
726
738
|
result.segno = true;
|
|
727
739
|
}
|
|
728
740
|
|
|
741
|
+
// Rehearsal mark (<direction-type><rehearsal>A</rehearsal>). May be split into
|
|
742
|
+
// multiple <rehearsal> (e.g. "A" + a Chinese gloss "段"); join them. Collected
|
|
743
|
+
// from the whole direction so it is found regardless of which direction-type it
|
|
744
|
+
// sits in. Surfaced as text → markup by directionToMarks (per the text-as-markup
|
|
745
|
+
// convention; LilyPond's musicxml2ly emits \mark \markup { \box { … } }).
|
|
746
|
+
const rehearsalEls = Array.from(dirEl.getElementsByTagName('rehearsal'));
|
|
747
|
+
if (rehearsalEls.length > 0) {
|
|
748
|
+
const text = rehearsalEls.map(el => el.textContent?.trim() || '').filter(Boolean).join(' ');
|
|
749
|
+
if (text) result.rehearsal = text;
|
|
750
|
+
}
|
|
751
|
+
|
|
729
752
|
return result;
|
|
730
753
|
};
|
|
731
754
|
|
|
@@ -811,6 +834,21 @@ const parseMetadata = (doc: Document): Metadata => {
|
|
|
811
834
|
metadata.title = movementTitleEl.textContent.trim();
|
|
812
835
|
}
|
|
813
836
|
|
|
837
|
+
// Subtitle: MusicXML has no <subtitle> element — it lives in a page <credit>
|
|
838
|
+
// tagged <credit-type>subtitle</credit-type>, with the text in <credit-words>.
|
|
839
|
+
const creditEls = doc.getElementsByTagName('credit');
|
|
840
|
+
for (let i = 0; i < creditEls.length; i++) {
|
|
841
|
+
const credit = creditEls[i];
|
|
842
|
+
const typeEl = credit.getElementsByTagName('credit-type')[0];
|
|
843
|
+
if (typeEl?.textContent?.trim().toLowerCase() === 'subtitle') {
|
|
844
|
+
const words = Array.from(credit.getElementsByTagName('credit-words'))
|
|
845
|
+
.map(w => w.textContent?.trim() || '')
|
|
846
|
+
.filter(Boolean)
|
|
847
|
+
.join(' ');
|
|
848
|
+
if (words) { metadata.subtitle = words; break; }
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
814
852
|
// Identification (composer, arranger, lyricist)
|
|
815
853
|
const identificationEl = doc.getElementsByTagName('identification')[0];
|
|
816
854
|
if (identificationEl) {
|
|
@@ -890,6 +928,12 @@ const notationsToMarks = (
|
|
|
890
928
|
// Articulations
|
|
891
929
|
if (notations.articulations) {
|
|
892
930
|
for (const artName of notations.articulations) {
|
|
931
|
+
// breath-mark is a NoDirection articulation in MusicXML but maps to
|
|
932
|
+
// LilyPond's \breathe — model it as the breath ornament.
|
|
933
|
+
if (artName === 'breath-mark') {
|
|
934
|
+
marks.push({ markType: 'ornament', type: OrnamentType.breath });
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
893
937
|
const artType = convertArticulation(artName);
|
|
894
938
|
if (artType) {
|
|
895
939
|
marks.push({ markType: 'articulation', type: artType });
|
|
@@ -909,7 +953,7 @@ const notationsToMarks = (
|
|
|
909
953
|
|
|
910
954
|
// Fermata
|
|
911
955
|
if (notations.fermata) {
|
|
912
|
-
marks.push({ markType: 'ornament', type: 'fermata' as any });
|
|
956
|
+
marks.push({ markType: 'ornament', type: (notations.fermata === 'short' ? 'shortFermata' : 'fermata') as any });
|
|
913
957
|
}
|
|
914
958
|
|
|
915
959
|
// Arpeggiate
|
|
@@ -917,6 +961,11 @@ const notationsToMarks = (
|
|
|
917
961
|
marks.push({ markType: 'ornament', type: 'arpeggio' as any });
|
|
918
962
|
}
|
|
919
963
|
|
|
964
|
+
// Glissando / slide (start note only)
|
|
965
|
+
if (notations.glissando) {
|
|
966
|
+
marks.push({ markType: 'glissando' });
|
|
967
|
+
}
|
|
968
|
+
|
|
920
969
|
return marks;
|
|
921
970
|
};
|
|
922
971
|
|
|
@@ -1082,16 +1131,31 @@ const directionToMarks = (
|
|
|
1082
1131
|
marks.push({ markType: 'navigation', type: NavigationMarkType.segno });
|
|
1083
1132
|
}
|
|
1084
1133
|
|
|
1134
|
+
// Rehearsal mark → markup (text-as-markup convention; LilyPond uses \mark).
|
|
1135
|
+
if (direction.rehearsal) {
|
|
1136
|
+
const placement = direction.placement === 'above' ? Placement.above
|
|
1137
|
+
: direction.placement === 'below' ? Placement.below
|
|
1138
|
+
: undefined;
|
|
1139
|
+
marks.push({ markType: 'markup', content: direction.rehearsal, placement });
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1085
1142
|
// Words (text directions: "dolce", "espr.", "cresc.", "con forza", ...).
|
|
1086
1143
|
// Tempo words ("Allegro", "a tempo", ...) are consumed separately as a tempo
|
|
1087
|
-
// ContextChange by directionToContextChange, so skip
|
|
1088
|
-
// double-emitting;
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1144
|
+
// ContextChange by directionToContextChange (which takes words[0]), so skip that
|
|
1145
|
+
// one here to avoid double-emitting; the REST become a markup mark → MEI <dir>.
|
|
1146
|
+
// A single <direction> often carries a tempo word AND an expressive/translation
|
|
1147
|
+
// word together (e.g. <words>Moderato</words><words>中板</words>) — only the tempo
|
|
1148
|
+
// word is consumed; the rest must still surface as markup. Metronome directions
|
|
1149
|
+
// are tempo too, never markup. Navigation text ("D.C. al Fine", "To Coda",
|
|
1150
|
+
// "Fine", "D.S. al Coda") is NOT a tempo word, so it flows here as a markup glyph
|
|
1151
|
+
// — the jump SEMANTICS are captured separately as measure-layout.
|
|
1092
1152
|
let emittedWords = false;
|
|
1093
1153
|
if (direction.words && direction.words.length > 0 && !direction.metronome) {
|
|
1094
|
-
|
|
1154
|
+
// If words[0] was consumed as the tempo text, drop it and keep the remainder;
|
|
1155
|
+
// otherwise the whole run is one expressive phrase, joined as today.
|
|
1156
|
+
const firstIsTempo = isTempoWord(direction.words[0].text.trim());
|
|
1157
|
+
const rest = firstIsTempo ? direction.words.slice(1) : direction.words;
|
|
1158
|
+
const text = rest.map(w => w.text).join('').trim();
|
|
1095
1159
|
if (text && !isTempoWord(text)) {
|
|
1096
1160
|
const placement = direction.placement === 'above' ? Placement.above
|
|
1097
1161
|
: direction.placement === 'below' ? Placement.below
|
|
@@ -1338,7 +1402,16 @@ const convertMeasure = (
|
|
|
1338
1402
|
// hairpin stop that lands just before a rest (common in piano scores:
|
|
1339
1403
|
// `<pedal stop/>` followed by rests filling the voice) is silently dropped.
|
|
1340
1404
|
const marks: Mark[] = note.isRest ? [] : (pendingMarks.get(voiceNum) || []);
|
|
1341
|
-
if (!note.isRest)
|
|
1405
|
+
if (!note.isRest) {
|
|
1406
|
+
pendingMarks.delete(voiceNum);
|
|
1407
|
+
// Drain directions that named this note's staff but had no known voice
|
|
1408
|
+
// yet (queued under the -staff placeholder key by the <direction> handler).
|
|
1409
|
+
const staffPending = pendingMarks.get(-staffNum);
|
|
1410
|
+
if (staffPending && staffPending.length > 0) {
|
|
1411
|
+
marks.push(...staffPending);
|
|
1412
|
+
pendingMarks.delete(-staffNum);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1342
1415
|
|
|
1343
1416
|
if (note.isRest) {
|
|
1344
1417
|
// Rest event
|
|
@@ -1366,7 +1439,7 @@ const convertMeasure = (
|
|
|
1366
1439
|
// so the encoder can emit <fermata startid="#rest">; without this the
|
|
1367
1440
|
// 3-of-4 fermatas that sit on rests in typical piano scores are lost.
|
|
1368
1441
|
if (note.notations?.fermata) {
|
|
1369
|
-
restEvent.marks = [{ markType: 'ornament', type: 'fermata' as any }];
|
|
1442
|
+
restEvent.marks = [{ markType: 'ornament', type: (note.notations.fermata === 'short' ? 'shortFermata' : 'fermata') as any }];
|
|
1370
1443
|
}
|
|
1371
1444
|
|
|
1372
1445
|
// Grace notes don't advance time
|
|
@@ -1480,9 +1553,17 @@ const convertMeasure = (
|
|
|
1480
1553
|
// Handle marks (dynamics, hairpins, etc.)
|
|
1481
1554
|
const marks = directionToMarks(direction, spannerTracker);
|
|
1482
1555
|
if (marks.length > 0) {
|
|
1483
|
-
//
|
|
1484
|
-
|
|
1485
|
-
|
|
1556
|
+
// Route to the voice the marks belong to. A <direction> in a grand-staff
|
|
1557
|
+
// part names its <staff>; the matching note is the NEXT note on that staff
|
|
1558
|
+
// — NOT currentVoice, which after a <backup> still points at the voice that
|
|
1559
|
+
// just ended (so a staff-2 direction emitted after the backup would wrongly
|
|
1560
|
+
// land on staff 1). Queue under a -staff placeholder key; the next note on
|
|
1561
|
+
// that staff drains it, and the end-of-measure flush falls back to that
|
|
1562
|
+
// staff's last note if no further note appears. Without a staff (single-
|
|
1563
|
+
// staff parts), fall back to currentVoice as before.
|
|
1564
|
+
const targetKey = direction.staff ? -direction.staff : currentVoice;
|
|
1565
|
+
const existing = pendingMarks.get(targetKey) || [];
|
|
1566
|
+
pendingMarks.set(targetKey, [...existing, ...marks]);
|
|
1486
1567
|
}
|
|
1487
1568
|
} else if (tagName === 'backup') {
|
|
1488
1569
|
// A <forward> with no note after it (before this backup) is cursor
|
|
@@ -1560,10 +1641,23 @@ const convertMeasure = (
|
|
|
1560
1641
|
}
|
|
1561
1642
|
return undefined;
|
|
1562
1643
|
};
|
|
1644
|
+
// A staff-keyed leftover (negative key = -staffNum, queued for a "next note on
|
|
1645
|
+
// this staff" that never came): resolve to the last note on any voice that lives
|
|
1646
|
+
// on that staff.
|
|
1647
|
+
const findLastNoteOnStaff = (staff: number): NoteEvent | undefined => {
|
|
1648
|
+
let best: NoteEvent | undefined;
|
|
1649
|
+
for (const [, vs] of allVoices) {
|
|
1650
|
+
if ((vs.staff || 1) !== staff) continue;
|
|
1651
|
+
for (let i = vs.events.length - 1; i >= 0; i--) {
|
|
1652
|
+
if (vs.events[i].type === 'note') { best = vs.events[i] as NoteEvent; break; }
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
return best;
|
|
1656
|
+
};
|
|
1563
1657
|
for (const [voiceNum, marks] of pendingMarks) {
|
|
1564
1658
|
if (marks.length === 0) continue;
|
|
1565
|
-
let target = findLastNote(voiceNum);
|
|
1566
|
-
// Voice had no note (e.g. direction-only or rest-only): attach to the
|
|
1659
|
+
let target = voiceNum < 0 ? findLastNoteOnStaff(-voiceNum) : findLastNote(voiceNum);
|
|
1660
|
+
// Voice/staff had no note (e.g. direction-only or rest-only): attach to the
|
|
1567
1661
|
// last note of any voice so the marking is not silently dropped.
|
|
1568
1662
|
if (!target) {
|
|
1569
1663
|
for (const vn of allVoices.keys()) {
|