@k-l-lambda/lilylet 0.1.75 → 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 +143 -125
- package/lib/lilylet/highlight.js +3 -1
- package/lib/lilylet/lilypondEncoder.js +10 -7
- package/lib/lilylet/measureLayout.d.ts +11 -0
- package/lib/lilylet/measureLayout.js +97 -0
- package/lib/lilylet/measureLayoutFromXml.d.ts +1 -0
- package/lib/lilylet/measureLayoutFromXml.js +3 -1
- package/lib/lilylet/meiEncoder.js +146 -70
- 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 +21 -2
- 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 +143 -125
- package/source/lilylet/highlight.ts +3 -1
- package/source/lilylet/lilylet.jison +33 -17
- package/source/lilylet/lilypondEncoder.ts +11 -7
- package/source/lilylet/measureLayout.ts +92 -0
- package/source/lilylet/measureLayoutFromXml.ts +3 -1
- package/source/lilylet/meiEncoder.ts +164 -66
- 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 +21 -2
- package/source/lilylet/types.ts +10 -1
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
} from "./types";
|
|
30
30
|
import { parseStaffLayout, StaffGroup, StaffGroupType } from "./staffLayout";
|
|
31
31
|
import { gmProgramOf } from "./gmInstruments";
|
|
32
|
-
import { parseMeasureLayout, expandMeasureLayout,
|
|
32
|
+
import { parseMeasureLayout, expandMeasureLayout, decomposeToSegments } from "./measureLayout";
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
// MEI key signatures: positive = sharps, negative = flats
|
|
@@ -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}"` : '';
|
|
@@ -2744,33 +2819,75 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2744
2819
|
const expansionInsertPos = mei.length;
|
|
2745
2820
|
const measureIds: string[] = [];
|
|
2746
2821
|
|
|
2747
|
-
// Volta
|
|
2748
|
-
//
|
|
2749
|
-
//
|
|
2750
|
-
//
|
|
2751
|
-
//
|
|
2752
|
-
//
|
|
2753
|
-
|
|
2754
|
-
const
|
|
2822
|
+
// Volta playback + house brackets. A flat measure-level <expansion> conflicts
|
|
2823
|
+
// with <ending> wrappers in verovio (it then drops the volta measures). Verovio
|
|
2824
|
+
// instead wants nested <section>/<ending> groups with a SECTION-LEVEL expansion
|
|
2825
|
+
// whose plist references those segment ids. So when the layout decomposes into
|
|
2826
|
+
// volta segments, emit that structure; otherwise keep the simpler flat path.
|
|
2827
|
+
// See [[volta-rendering-gaps]] / [[lilylet-measure-layout]].
|
|
2828
|
+
// Helper to check if a measure has any musical content
|
|
2829
|
+
const measureHasContent = (measure: Measure): boolean => {
|
|
2830
|
+
for (const part of measure.parts) {
|
|
2831
|
+
for (const voice of part.voices) {
|
|
2832
|
+
for (const event of voice.events) {
|
|
2833
|
+
// Check for actual musical content (not just context changes or pitch resets)
|
|
2834
|
+
if (event.type === 'note' || event.type === 'rest' ||
|
|
2835
|
+
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2836
|
+
return true;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
return false;
|
|
2842
|
+
};
|
|
2843
|
+
|
|
2844
|
+
// Filter out trailing empty measures (done before segment setup so the segment
|
|
2845
|
+
// guard below knows the true emitted measure count).
|
|
2846
|
+
let measures = doc.measures;
|
|
2847
|
+
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2848
|
+
measures = measures.slice(0, -1);
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
let segDecomp: ReturnType<typeof decomposeToSegments> = null;
|
|
2852
|
+
const segOpenAt = new Map<number, string>(); // measure index → opening tag for its segment
|
|
2853
|
+
const segCloseAt = new Map<number, string>(); // measure index → closing tag after it
|
|
2854
|
+
const segIdById = new Map<string, string>(); // layout segment id → emitted xml:id
|
|
2755
2855
|
if (doc.metadata?.measureLayout) {
|
|
2756
2856
|
try {
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2857
|
+
segDecomp = decomposeToSegments(parseMeasureLayout(doc.metadata.measureLayout));
|
|
2858
|
+
} catch { segDecomp = null; }
|
|
2859
|
+
}
|
|
2860
|
+
// A layout that references a measure beyond the emitted range (e.g. a volta
|
|
2861
|
+
// alternate whose bar was a trailing empty measure that got trimmed) cannot be
|
|
2862
|
+
// emitted as nested sections without a dangling <ending>/<section> id or an
|
|
2863
|
+
// unbalanced wrapper. Drop to the flat measure-level expansion path, which
|
|
2864
|
+
// already warns-and-skips out-of-range refs.
|
|
2865
|
+
if (segDecomp && segDecomp.segments.some(seg => seg.measures.some(m => m > measures.length))) {
|
|
2866
|
+
segDecomp = null;
|
|
2867
|
+
}
|
|
2868
|
+
if (segDecomp) {
|
|
2869
|
+
const segIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2870
|
+
for (const seg of segDecomp.segments) {
|
|
2871
|
+
const xmlId = generateId(seg.kind === "ending" ? "ending" : "section");
|
|
2872
|
+
segIdById.set(seg.id, xmlId);
|
|
2873
|
+
const lo = seg.measures[0];
|
|
2874
|
+
const hi = seg.measures[seg.measures.length - 1];
|
|
2875
|
+
if (seg.kind === "ending") {
|
|
2876
|
+
const lendsym = seg.endingNumber === 1 ? ' lendsym="angledown"' : '';
|
|
2877
|
+
segOpenAt.set(lo, `${segIndent}<ending xml:id="${xmlId}" n="${seg.endingNumber}"${lendsym}>\n`);
|
|
2878
|
+
} else {
|
|
2879
|
+
segOpenAt.set(lo, `${segIndent}<section xml:id="${xmlId}">\n`);
|
|
2764
2880
|
}
|
|
2765
|
-
|
|
2881
|
+
segCloseAt.set(hi, `${segIndent}</${seg.kind === "ending" ? "ending" : "section"}>\n`);
|
|
2882
|
+
}
|
|
2766
2883
|
}
|
|
2767
|
-
let endingOpen = false; // whether an <ending> is currently open in the output
|
|
2768
2884
|
|
|
2769
2885
|
// Track tie state across measures for cross-measure ties
|
|
2770
2886
|
const tieState: TieState = {};
|
|
2771
2887
|
|
|
2772
2888
|
// Track slur state across measures for cross-measure slurs
|
|
2773
2889
|
const slurState: SlurState = {};
|
|
2890
|
+
const glissState: GlissState = {};
|
|
2774
2891
|
|
|
2775
2892
|
// Track hairpin state across measures for cross-measure hairpins
|
|
2776
2893
|
const hairpinState: HairpinState = {};
|
|
@@ -2796,28 +2913,6 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2796
2913
|
}
|
|
2797
2914
|
}
|
|
2798
2915
|
|
|
2799
|
-
// Helper to check if a measure has any musical content
|
|
2800
|
-
const measureHasContent = (measure: Measure): boolean => {
|
|
2801
|
-
for (const part of measure.parts) {
|
|
2802
|
-
for (const voice of part.voices) {
|
|
2803
|
-
for (const event of voice.events) {
|
|
2804
|
-
// Check for actual musical content (not just context changes or pitch resets)
|
|
2805
|
-
if (event.type === 'note' || event.type === 'rest' ||
|
|
2806
|
-
event.type === 'tuplet' || event.type === 'times' || event.type === 'tremolo') {
|
|
2807
|
-
return true;
|
|
2808
|
-
}
|
|
2809
|
-
}
|
|
2810
|
-
}
|
|
2811
|
-
}
|
|
2812
|
-
return false;
|
|
2813
|
-
};
|
|
2814
|
-
|
|
2815
|
-
// Filter out trailing empty measures
|
|
2816
|
-
let measures = doc.measures;
|
|
2817
|
-
while (measures.length > 0 && !measureHasContent(measures[measures.length - 1])) {
|
|
2818
|
-
measures = measures.slice(0, -1);
|
|
2819
|
-
}
|
|
2820
|
-
|
|
2821
2916
|
// Encode measures
|
|
2822
2917
|
measures.forEach((measure, mi) => {
|
|
2823
2918
|
// Check for key signature change and output scoreDef if needed
|
|
@@ -2877,29 +2972,32 @@ const encode = (doc: LilyletDoc, options: MEIEncoderOptions = {}): string => {
|
|
|
2877
2972
|
}
|
|
2878
2973
|
const mIndex = mi + 1;
|
|
2879
2974
|
const mIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2880
|
-
//
|
|
2881
|
-
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds);
|
|
2889
|
-
// Close the <ending> after the measure that ends this alternate.
|
|
2890
|
-
if (endingOpen && endingCloseAt.has(mIndex)) {
|
|
2891
|
-
mei += `${mIndent}</ending>\n`;
|
|
2892
|
-
endingOpen = false;
|
|
2893
|
-
}
|
|
2975
|
+
// When the layout decomposes into volta segments, wrap each measure run in
|
|
2976
|
+
// its <section>/<ending> container (opened/closed by measure index).
|
|
2977
|
+
const openTag = segOpenAt.get(mIndex);
|
|
2978
|
+
if (openTag) mei += openTag;
|
|
2979
|
+
const measureIndent = segDecomp ? `${mIndent}${indent}` : mIndent;
|
|
2980
|
+
mei += encodeMeasure(measure, mIndex, measureIndent, totalStaves, tieState, slurState, hairpinState, ottavaState, currentKey, partInfos, clefState, octaveEndReplacements, measureIds, glissState);
|
|
2981
|
+
const closeTag = segCloseAt.get(mIndex);
|
|
2982
|
+
if (closeTag) mei += closeTag;
|
|
2894
2983
|
});
|
|
2895
|
-
// Defensive: never leave an <ending> unclosed at the end of the section.
|
|
2896
|
-
if (endingOpen) { mei += `${indent}${indent}${indent}${indent}${indent}${indent}</ending>\n`; endingOpen = false; }
|
|
2897
2984
|
|
|
2898
2985
|
// Emit an <expansion> describing playback order from the measure-layout
|
|
2899
|
-
// directive ([measures "..."]). Verovio
|
|
2900
|
-
//
|
|
2901
|
-
//
|
|
2902
|
-
|
|
2986
|
+
// directive ([measures "..."]). Verovio consumes its plist to unfold repeats
|
|
2987
|
+
// for MIDI/time-map output. Inserted as the first child of <section>.
|
|
2988
|
+
// - voltas present (segDecomp): plist references the SECTION/ENDING segment
|
|
2989
|
+
// ids in performed order (a body segment id repeats per pass). This is the
|
|
2990
|
+
// only form verovio plays correctly alongside <ending> house brackets.
|
|
2991
|
+
// - otherwise: the simpler flat measure-level plist (one ref per played bar).
|
|
2992
|
+
if (segDecomp) {
|
|
2993
|
+
const refs = segDecomp.order.map(segId => segIdById.get(segId)).filter((x): x is string => x !== undefined).map(id => `#${id}`);
|
|
2994
|
+
if (refs.length > 0) {
|
|
2995
|
+
const expIndent = `${indent}${indent}${indent}${indent}${indent}${indent}`;
|
|
2996
|
+
const expXml = `${expIndent}<expansion xml:id="${generateId("expansion")}" plist="${refs.join(" ")}" />\n`;
|
|
2997
|
+
mei = mei.slice(0, expansionInsertPos) + expXml + mei.slice(expansionInsertPos);
|
|
2998
|
+
}
|
|
2999
|
+
}
|
|
3000
|
+
else if (doc.metadata?.measureLayout) {
|
|
2903
3001
|
try {
|
|
2904
3002
|
const layout = parseMeasureLayout(doc.metadata.measureLayout);
|
|
2905
3003
|
const order = expandMeasureLayout(layout);
|